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}