Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions rust-executor/src/agent/capabilities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> bool {
match admin_credential {
Some(cred) => constant_time_eq(token, cred),
None => token.is_empty(),
}
}

pub fn check_capability(
capabilities: &Result<Vec<Capability>, String>,
expected: &Capability,
Expand Down
4 changes: 4 additions & 0 deletions rust-executor/src/graphql/graphql_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
20 changes: 14 additions & 6 deletions rust-executor/src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<ConnectionConfig<_>, Infallible>
Expand Down Expand Up @@ -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<ConnectionConfig<_>, Infallible>
Expand Down
20 changes: 7 additions & 13 deletions rust-executor/src/graphql/query_resolvers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}",
Expand Down