use std::sync::Arc;
use crate::Decision;
use crate::grant::{CapabilityGrant, PolicyError};
#[derive(Debug, Clone)]
pub struct ResourceOp {
pub cap_id: String,
pub key: String,
pub action: String,
pub attrs: serde_json::Value,
}
#[async_trait::async_trait]
pub trait CapabilityProvider: Send + Sync {
async fn resolve(
&self,
cap_id: &str,
declared: Option<&[serde_json::Value]>,
grant: &CapabilityGrant,
) -> Result<Box<dyn CompiledCeiling>, PolicyError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Explained {
pub decision: Decision,
pub rule: Option<String>,
}
pub trait CompiledCeiling: Send + Sync {
fn classify(&self, op: &ResourceOp) -> Decision;
fn classify_explained(&self, op: &ResourceOp) -> Explained {
Explained {
decision: self.classify(op),
rule: None,
}
}
fn declared(&self) -> bool;
fn effective_mode(&self) -> crate::grant::PolicyMode {
crate::grant::PolicyMode::Deny
}
fn tag(&self) -> &'static str {
""
}
}
pub struct ProviderRegistry {
entries: Vec<(String, Arc<dyn CapabilityProvider>)>,
generic: Arc<dyn CapabilityProvider>,
}
impl ProviderRegistry {
pub fn new(generic: Arc<dyn CapabilityProvider>) -> Self {
Self {
entries: Vec::new(),
generic,
}
}
pub fn register(&mut self, pattern: &str, provider: Arc<dyn CapabilityProvider>) {
self.entries.push((pattern.to_string(), provider));
}
pub fn lookup(&self, cap_id: &str) -> &Arc<dyn CapabilityProvider> {
if let Some((_, p)) = self.entries.iter().find(|(k, _)| k == cap_id) {
return p;
}
let mut best: Option<(&str, &Arc<dyn CapabilityProvider>)> = None;
for (k, p) in &self.entries {
if let Some(prefix) = k.strip_suffix('*')
&& cap_id.starts_with(prefix)
&& best.is_none_or(|(bk, _)| prefix.len() > bk.len() - 1)
{
best = Some((k, p));
}
}
best.map_or(&self.generic, |(_, p)| p)
}
pub fn with_builtins() -> Self {
let mut r = Self::new(Arc::new(crate::providers::generic::GenericProvider));
r.register(
"wasi:filesystem",
Arc::new(crate::providers::fs::FsProvider),
);
r.register("wasi:http", Arc::new(crate::providers::http::HttpProvider));
r.register(
"wasi:sockets",
Arc::new(crate::providers::sockets::SocketsProvider),
);
r.register(
crate::providers::credentials::CAP_CREDENTIALS,
Arc::new(crate::providers::credentials::CredentialsProvider),
);
r
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
struct Tagged(&'static str);
#[async_trait::async_trait]
impl CapabilityProvider for Tagged {
async fn resolve(
&self,
_id: &str,
_declared: Option<&[serde_json::Value]>,
_grant: &crate::grant::CapabilityGrant,
) -> Result<Box<dyn CompiledCeiling>, crate::grant::PolicyError> {
Ok(Box::new(TagCeiling(self.0)))
}
}
struct TagCeiling(&'static str);
impl CompiledCeiling for TagCeiling {
fn classify(&self, _op: &ResourceOp) -> crate::Decision {
crate::Decision::Deny
}
fn declared(&self) -> bool {
true
}
fn tag(&self) -> &'static str {
self.0
}
}
#[tokio::test]
async fn lookup_prefers_exact_then_longest_prefix_then_generic() {
let mut r = ProviderRegistry::new(Arc::new(Tagged("generic")));
r.register("wasi:http", Arc::new(Tagged("http")));
r.register("db:*", Arc::new(Tagged("db-wild")));
r.register("db:drop-*", Arc::new(Tagged("db-drop")));
async fn tag(r: &ProviderRegistry, id: &str) -> String {
r.lookup(id)
.resolve(id, None, &Default::default())
.await
.unwrap()
.tag()
.to_string()
}
assert_eq!(tag(&r, "wasi:http").await, "http"); assert_eq!(tag(&r, "db:truncate").await, "db-wild"); assert_eq!(tag(&r, "db:drop-database").await, "db-drop"); assert_eq!(tag(&r, "email:send").await, "generic"); }
#[tokio::test]
async fn credentials_provider_is_registered_not_generic_fallback() {
let r = ProviderRegistry::with_builtins();
let provider = r.lookup(crate::providers::credentials::CAP_CREDENTIALS);
let ask_grant = crate::grant::CapabilityGrant {
mode: crate::grant::PolicyMode::Ask,
allow: vec![],
deny: vec![],
};
let ceiling = provider
.resolve(
crate::providers::credentials::CAP_CREDENTIALS,
None,
&ask_grant,
)
.await
.unwrap();
let cred_op = ResourceOp {
cap_id: crate::providers::credentials::CAP_CREDENTIALS.into(),
key: "test-cred".into(),
action: "get".into(),
attrs: serde_json::Value::Null,
};
let explained = ceiling.classify_explained(&cred_op);
assert_eq!(explained.decision, crate::Decision::Deny);
assert_eq!(
explained.rule,
Some("act:credentials not declared in act:component".to_string()),
"this exact, capability-named rule is only attributed by \
CredentialsCeiling; the generic fallback's undeclared-deny path \
attributes a different, capability-agnostic rule"
);
}
}