diff --git a/rust-executor/src/agent/capabilities/mod.rs b/rust-executor/src/agent/capabilities/mod.rs index 125b25465..0f13812a1 100644 --- a/rust-executor/src/agent/capabilities/mod.rs +++ b/rust-executor/src/agent/capabilities/mod.rs @@ -34,6 +34,16 @@ lazy_static! { const CACHE_TTL_SECONDS: i64 = 300; // 5 minutes cache TTL +/// Returns true if the given token is the admin_credential that grants launcher-level access. +/// When admin_credential is Some, the token must match it exactly (constant-time). +/// When admin_credential is None (legacy single-user mode), an empty token is treated as admin. +pub fn is_admin_credential_token(token: &str, admin_credential: &Option) -> bool { + match admin_credential { + Some(cred) => constant_time_eq(token, cred), + None => token.is_empty(), + } +} + pub fn check_capability( capabilities: &Result, String>, expected: &Capability, diff --git a/rust-executor/src/graphql/graphql_types.rs b/rust-executor/src/graphql/graphql_types.rs index fb806ab2b..3e11f0a6d 100644 --- a/rust-executor/src/graphql/graphql_types.rs +++ b/rust-executor/src/graphql/graphql_types.rs @@ -18,6 +18,10 @@ pub struct RequestContext { pub js_handle: JsCoreHandle, pub auto_permit_cap_requests: bool, pub auth_token: String, + /// True when the request was authenticated with the launcher's admin_credential token. + /// Only the launcher (or legacy single-user empty-token) qualifies. + /// Regular app tokens (JWT) never have this set, regardless of which capabilities they hold. + pub is_admin_credential: bool, } #[derive(GraphQLObject, Default, Debug, Deserialize, Serialize, Clone)] diff --git a/rust-executor/src/graphql/mod.rs b/rust-executor/src/graphql/mod.rs index 2a42594b8..76f22e700 100644 --- a/rust-executor/src/graphql/mod.rs +++ b/rust-executor/src/graphql/mod.rs @@ -10,7 +10,7 @@ use reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN; use subscription_resolvers::*; use warp::reply::with_header; -use crate::agent::capabilities::capabilities_from_token; +use crate::agent::capabilities::{capabilities_from_token, is_admin_credential_token}; use crate::js_core::JsCoreHandle; use crate::Ad4mConfig; @@ -78,11 +78,13 @@ pub async fn start_server( //println!("Request body: {}", std::str::from_utf8(body_data::bytes()).expect("error converting bytes to &str")); let capabilities = capabilities_from_token(auth_header.clone(), admin_credential.clone()); + let is_admin_credential = is_admin_credential_token(&auth_header, &admin_credential); RequestContext { capabilities, js_handle: js_core_handle_cloned1.clone(), auto_permit_cap_requests: config.auto_permit_cap_requests.unwrap_or(false), auth_token: auth_header, + is_admin_credential, } }); let qm_graphql_filter = coasys_juniper_warp::make_graphql_filter(qm_schema, qm_state.boxed()); @@ -123,16 +125,18 @@ pub async fn start_server( crate::agent::capabilities::track_last_seen_from_token(auth_header.clone()) .await; - let capabilities = capabilities_from_token( - auth_header.clone(), - admin_credential_arc.as_ref().clone(), - ); + let admin_credential = admin_credential_arc.as_ref().clone(); + let capabilities = + capabilities_from_token(auth_header.clone(), admin_credential.clone()); + let is_admin_credential = + is_admin_credential_token(&auth_header, &admin_credential); let context = RequestContext { capabilities, js_handle: js_core_handle.clone(), auto_permit_cap_requests, auth_token: auth_header, + is_admin_credential, }; Ok(ConnectionConfig::new(context)) as Result, Infallible> @@ -262,16 +266,20 @@ pub async fn start_server( ) .await; + let admin_credential = admin_credential_arc.as_ref().clone(); let capabilities = capabilities_from_token( auth_header.clone(), - admin_credential_arc.as_ref().clone(), + admin_credential.clone(), ); + let is_admin_credential = + is_admin_credential_token(&auth_header, &admin_credential); let context = RequestContext { capabilities, js_handle: js_core_handle.clone(), auto_permit_cap_requests: auto_permit_cap_requests2, auth_token: auth_header, + is_admin_credential, }; Ok(ConnectionConfig::new(context)) as Result, Infallible> diff --git a/rust-executor/src/graphql/query_resolvers.rs b/rust-executor/src/graphql/query_resolvers.rs index 98d118bad..72fd344e9 100644 --- a/rust-executor/src/graphql/query_resolvers.rs +++ b/rust-executor/src/graphql/query_resolvers.rs @@ -557,27 +557,21 @@ impl Query { let mut result = Vec::new(); - // Extract user DID from token for multi-user filtering + // Extract user email from token for multi-user ownership filtering let user_email = user_email_from_token(context.auth_token.clone()); - // Check if this is an admin request (for launcher/debugging) - // Admin capability has wildcards in domain/pointers/can - let has_all_caps = match &context.capabilities { - Ok(caps) => caps - .iter() - .any(|cap| cap.with.domain == "*" && cap.can.iter().any(|c| c == "*")), - Err(_) => false, - }; - - let is_admin = has_all_caps && user_email.is_none(); + // Only the launcher (authenticated via admin_credential) gets the full overview. + // Regular app tokens (JWT) — even those granted ALL_CAPABILITY — are not considered admin + // here and will only see perspectives they own or have joined. + let is_admin = context.is_admin_credential; for p in all_perspectives().iter() { let mut handle = p.persisted.lock().await.clone(); - log::debug!("📋 perspectives(): perspective {} has owners: {:?}, is_admin: {}, user_email: {:?}", + log::debug!("📋 perspectives(): perspective {} has owners: {:?}, is_admin: {}, user_email: {:?}", handle.uuid, handle.owners, is_admin, user_email); - // Admin sees all perspectives (for launcher), otherwise filter by ownership + // Admin (launcher) sees all perspectives for the overview; others filter by ownership if is_admin { log::debug!( "📋 perspectives(): is_admin: true, Including perspective {}",