#![cfg(feature = "admin")]
use std::collections::BTreeSet;
use std::sync::Arc;
use boatramp_core::config::SiteConfig;
use boatramp_core::deploy::DeployStore;
use boatramp_core::email_config::{EmailProfilePatch, EmailProfileStore};
use boatramp_core::envelope::{EnvelopeError, KeyEnvelope};
use boatramp_core::kv::MemoryKv;
use boatramp_core::project::ProjectRef;
use boatramp_core::secret_store::SecretStore;
use boatramp_core::security::{SecurityPosture, SecurityProfile};
use boatramp_handlers::{AdminController, AdminError, AdminSurface};
use boatramp_server::ServerAdminController;
use boatramp_storage::FsStorage;
struct NoopEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for NoopEnvelope {
async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(p.to_vec())
}
async fn unwrap(&self, c: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(c.to_vec())
}
}
fn surfaces_for(p: &SecurityPosture) -> BTreeSet<AdminSurface> {
let mut s = BTreeSet::new();
if p.allow_guest_admin_domains {
s.insert(AdminSurface::Domains);
}
if p.allow_guest_admin_email {
s.insert(AdminSurface::Email);
}
if p.allow_guest_admin_site {
s.insert(AdminSurface::Site);
}
if p.allow_guest_admin_secrets {
s.insert(AdminSurface::Secrets);
}
s
}
fn email_patch(host: &str, from: &str) -> EmailProfilePatch {
EmailProfilePatch {
host: Some(host.into()),
from: Some(from.into()),
..Default::default()
}
}
#[tokio::test]
#[ignore = "capability gate: run via capability.yml or with --ignored"]
async fn admin_capability_holds_the_tenant_invariants() {
let kv = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(Arc::new(FsStorage::new(std::env::temp_dir())), kv.clone());
let email = Arc::new(EmailProfileStore::new(kv.clone(), Arc::new(NoopEnvelope)));
let secret = Arc::new(SecretStore::new(kv.clone(), Arc::new(NoopEnvelope)));
let make = |project: &'static str| -> Arc<dyn AdminController> {
ServerAdminController::with_server_probe(
deploy.clone(),
Some(email.clone()),
Some(secret.clone()),
true, )
.scoped(ProjectRef::new(project))
};
let globex = make("globex");
let victim_cfg = SiteConfig {
security: boatramp_core::config::SecurityConfig {
csp: Some("GLOBEX-ONLY-CSP".into()),
..Default::default()
},
..Default::default()
};
globex
.site_config_put("blog", &serde_json::to_string(&victim_cfg).unwrap())
.await
.expect("globex writes its own config");
globex
.secret_set("globex-key", b"TOPSECRET")
.await
.expect("globex writes its own secret");
globex
.email_set(
"globex-smtp",
email_patch("smtp.globex.test", "x@globex.test"),
)
.await
.expect("globex writes its own email profile");
let acme = make("acme");
assert!(
acme.secret_list().await.unwrap().is_empty(),
"acme must not see globex's secrets"
);
assert!(
acme.email_list().await.unwrap().is_empty(),
"acme must not see globex's email profiles"
);
assert!(
matches!(
acme.site_config_get("blog").await,
Err(AdminError::NotFound(_))
),
"acme's own 'blog' is unset (and is NOT globex's config)"
);
for evil in [
"../globex/site/blog",
"..%2fglobex%2fsite%2fblog",
"globex/site/blog",
"../../project/globex/site/blog",
"blog/../../globex/site/blog",
] {
let got = acme.site_config_get(evil).await;
assert!(
!matches!(&got, Ok(s) if s.contains("GLOBEX-ONLY-CSP")),
"site-string escape via {evil:?} leaked globex's config: {got:?}"
);
}
assert!(
globex
.site_config_get("blog")
.await
.unwrap()
.contains("GLOBEX-ONLY-CSP"),
"globex reads back its own marked config"
);
assert_eq!(
globex.secret_list().await.unwrap(),
vec!["globex-key".to_string()],
"the secret surface returns names only, never the sealed value"
);
assert_eq!(
globex.email_list().await.unwrap(),
vec!["globex-smtp".to_string()],
"the email surface returns names only, never the sealed password"
);
let mt = SecurityProfile::MultiTenant.preset();
assert!(
surfaces_for(&mt).is_empty(),
"multi-tenant must enable NO guest-admin surface (untrusted-tenant default)"
);
assert_eq!(
surfaces_for(&SecurityProfile::SingleTenant.preset()).len(),
4,
"single-tenant opts into every surface (the operator owns every site)"
);
assert_eq!(
surfaces_for(&SecurityProfile::Dev.preset()).len(),
4,
"dev opts into every surface"
);
let mut limited = false;
for i in 0..64 {
if matches!(
acme.secret_set(&format!("k{i}"), b"v").await,
Err(AdminError::RateLimited)
) {
limited = true;
break;
}
}
assert!(
limited,
"a runaway admin loop must hit the per-project rate quota"
);
println!(
"ADMIN CAPABILITY GATE OK: cross-tenant reads impossible, site-string escapes blocked, \
credentials write-only, multi-tenant locked down, rate-limited"
);
}