greentic_component/
security.rs

1use std::collections::HashSet;
2
3use crate::capabilities::{
4    Capabilities, CapabilityError, FsCaps, HttpCaps, KvCaps, NetCaps, SecretsCaps, ToolsCaps,
5};
6use crate::manifest::ComponentManifest;
7
8#[derive(Debug, Clone, Default)]
9pub struct Profile {
10    pub allowed: Capabilities,
11}
12
13impl Profile {
14    pub fn new(allowed: Capabilities) -> Self {
15        Self { allowed }
16    }
17}
18
19pub fn enforce_capabilities(
20    manifest: &ComponentManifest,
21    profile: Profile,
22) -> Result<(), CapabilityError> {
23    let requested = &manifest.capabilities;
24    let allowed = &profile.allowed;
25
26    if let Some(http) = &requested.http {
27        ensure_http(http, allowed.http.as_ref())?;
28    }
29    if let Some(secrets) = &requested.secrets {
30        ensure_secrets(secrets, allowed.secrets.as_ref())?;
31    }
32    if let Some(kv) = &requested.kv {
33        ensure_kv(kv, allowed.kv.as_ref())?;
34    }
35    if let Some(fs) = &requested.fs {
36        ensure_fs(fs, allowed.fs.as_ref())?;
37    }
38    if let Some(net) = &requested.net {
39        ensure_net(net, allowed.net.as_ref())?;
40    }
41    if let Some(tools) = &requested.tools {
42        ensure_tools(tools, allowed.tools.as_ref())?;
43    }
44
45    Ok(())
46}
47
48fn ensure_http(requested: &HttpCaps, allowed: Option<&HttpCaps>) -> Result<(), CapabilityError> {
49    let policy = allowed.ok_or_else(|| {
50        CapabilityError::denied(
51            "http",
52            "capabilities.http",
53            "profile does not permit outbound HTTP",
54        )
55    })?;
56
57    let allowed_domains: HashSet<_> = policy.domains.iter().collect();
58    for domain in &requested.domains {
59        if !allowed_domains.contains(domain) {
60            return Err(CapabilityError::denied(
61                "http",
62                format!("capabilities.http.domains[{domain}]"),
63                format!("domain `{domain}` is not allowed"),
64            ));
65        }
66    }
67
68    if requested.allow_insecure && !policy.allow_insecure {
69        return Err(CapabilityError::denied(
70            "http",
71            "capabilities.http.allow_insecure",
72            "insecure HTTP is disabled for this profile",
73        ));
74    }
75
76    Ok(())
77}
78
79fn ensure_secrets(
80    requested: &SecretsCaps,
81    allowed: Option<&SecretsCaps>,
82) -> Result<(), CapabilityError> {
83    let policy = allowed.ok_or_else(|| {
84        CapabilityError::denied(
85            "secrets",
86            "capabilities.secrets",
87            "profile denies access to secrets",
88        )
89    })?;
90
91    let allowed_scopes: HashSet<_> = policy.scopes.iter().collect();
92    for scope in &requested.scopes {
93        if !allowed_scopes.contains(scope) {
94            return Err(CapabilityError::denied(
95                "secrets",
96                format!("capabilities.secrets.scopes[{scope}]"),
97                format!("scope `{scope}` is not part of the profile"),
98            ));
99        }
100    }
101    Ok(())
102}
103
104fn ensure_kv(requested: &KvCaps, allowed: Option<&KvCaps>) -> Result<(), CapabilityError> {
105    let policy = allowed.ok_or_else(|| {
106        CapabilityError::denied("kv", "capabilities.kv", "profile denies kv access")
107    })?;
108
109    let allowed_buckets: HashSet<_> = policy.buckets.iter().collect();
110    for bucket in &requested.buckets {
111        if !allowed_buckets.contains(bucket) {
112            return Err(CapabilityError::denied(
113                "kv",
114                format!("capabilities.kv.buckets[{bucket}]"),
115                format!("bucket `{bucket}` is unavailable"),
116            ));
117        }
118    }
119
120    if requested.read && !policy.read {
121        return Err(CapabilityError::denied(
122            "kv",
123            "capabilities.kv.read",
124            "read access denied by profile",
125        ));
126    }
127
128    if requested.write && !policy.write {
129        return Err(CapabilityError::denied(
130            "kv",
131            "capabilities.kv.write",
132            "write access denied by profile",
133        ));
134    }
135
136    Ok(())
137}
138
139fn ensure_fs(requested: &FsCaps, allowed: Option<&FsCaps>) -> Result<(), CapabilityError> {
140    let policy = allowed.ok_or_else(|| {
141        CapabilityError::denied("fs", "capabilities.fs", "profile denies filesystem mounts")
142    })?;
143
144    let allowed_paths: HashSet<_> = policy.paths.iter().collect();
145    for path in &requested.paths {
146        if !allowed_paths.contains(path) {
147            return Err(CapabilityError::denied(
148                "fs",
149                format!("capabilities.fs.paths[{path}]"),
150                format!("path `{path}` is not mounted in this profile"),
151            ));
152        }
153    }
154
155    if !requested.read_only && policy.read_only {
156        return Err(CapabilityError::denied(
157            "fs",
158            "capabilities.fs.read_only",
159            "profile exposes filesystem as read-only",
160        ));
161    }
162
163    Ok(())
164}
165
166fn ensure_net(requested: &NetCaps, allowed: Option<&NetCaps>) -> Result<(), CapabilityError> {
167    let policy = allowed.ok_or_else(|| {
168        CapabilityError::denied(
169            "net",
170            "capabilities.net",
171            "profile denies outbound network access",
172        )
173    })?;
174
175    if !requested.hosts.is_empty() {
176        if policy.hosts.is_empty() {
177            return Err(CapabilityError::denied(
178                "net",
179                "capabilities.net.hosts",
180                "profile did not pre-authorise hosts",
181            ));
182        }
183        let allowed_hosts: HashSet<_> = policy.hosts.iter().collect();
184        for host in &requested.hosts {
185            if !allowed_hosts.contains(host) {
186                return Err(CapabilityError::denied(
187                    "net",
188                    format!("capabilities.net.hosts[{host}]"),
189                    format!("host `{host}` is blocked"),
190                ));
191            }
192        }
193    }
194
195    if requested.allow_tcp && !policy.allow_tcp {
196        return Err(CapabilityError::denied(
197            "net",
198            "capabilities.net.allow_tcp",
199            "TCP access disabled",
200        ));
201    }
202
203    if requested.allow_udp && !policy.allow_udp {
204        return Err(CapabilityError::denied(
205            "net",
206            "capabilities.net.allow_udp",
207            "UDP access disabled",
208        ));
209    }
210
211    Ok(())
212}
213
214fn ensure_tools(requested: &ToolsCaps, allowed: Option<&ToolsCaps>) -> Result<(), CapabilityError> {
215    let policy = allowed.ok_or_else(|| {
216        CapabilityError::denied(
217            "tools",
218            "capabilities.tools",
219            "no tools allowed for this profile",
220        )
221    })?;
222
223    let allowed: HashSet<_> = policy.allow.iter().collect();
224    for tool in &requested.allow {
225        if !allowed.contains(tool) {
226            return Err(CapabilityError::denied(
227                "tools",
228                format!("capabilities.tools.allow[{tool}]"),
229                format!("tool `{tool}` cannot be invoked"),
230            ));
231        }
232    }
233
234    Ok(())
235}