Skip to main content

alien_core/
remote_bindings.rs

1use crate::{
2    ownership_policy_for_resource_type, ResourceEntry, ResourceType, Sandbox, SandboxEgress,
3};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum RemoteBindingKind {
7    Storage,
8    Kv,
9    Queue,
10    Key,
11    Ai,
12    Sandbox,
13}
14
15/// One resource type's provider-neutral Remote Bindings contract.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct RemoteBindingDefinition {
18    pub resource_type: &'static str,
19    pub permission_set: &'static str,
20    pub kind: RemoteBindingKind,
21    pub description: &'static str,
22    /// Setup-owned parent resources that this binding kind may require. They do not turn a
23    /// bindings-only stack into an application stack.
24    pub setup_support_resource_types: &'static [&'static str],
25    /// Increment when the permission set's effective grants change. This makes direct setup
26    /// updates reconcile permissions even when the application resource config is unchanged.
27    pub revision: u32,
28}
29
30const DEFINITIONS: &[RemoteBindingDefinition] = &[
31    RemoteBindingDefinition {
32        resource_type: "storage",
33        permission_set: "storage/remote-data-write",
34        kind: RemoteBindingKind::Storage,
35        description: "Read and write objects in this storage resource",
36        setup_support_resource_types: &[
37            "azure_resource_group",
38            "azure_storage_account",
39            "service_activation",
40        ],
41        revision: 1,
42    },
43    RemoteBindingDefinition {
44        resource_type: "queue",
45        permission_set: "queue/publish",
46        kind: RemoteBindingKind::Queue,
47        description: "Send messages to this queue",
48        setup_support_resource_types: &["azure_resource_group", "azure_service_bus_namespace", "service_activation"],
49        revision: 1,
50    },
51    RemoteBindingDefinition {
52        resource_type: "kv",
53        permission_set: "kv/remote-data-write",
54        kind: RemoteBindingKind::Kv,
55        description: "Read and write entries in this key-value store",
56        setup_support_resource_types: &[
57            "azure_resource_group",
58            "azure_storage_account",
59            "service_activation",
60        ],
61        revision: 1,
62    },
63    RemoteBindingDefinition {
64        resource_type: "key",
65        permission_set: "key/remote-cryptography",
66        kind: RemoteBindingKind::Key,
67        description: "Encrypt and decrypt small values with this key",
68        setup_support_resource_types: &["azure_resource_group", "service_activation"],
69        revision: 1,
70    },
71    RemoteBindingDefinition {
72        resource_type: "ai",
73        permission_set: "ai/invoke",
74        kind: RemoteBindingKind::Ai,
75        description: "Invoke models through this AI resource",
76        setup_support_resource_types: &["azure_resource_group", "service_activation"],
77        revision: 1,
78    },
79    RemoteBindingDefinition {
80        resource_type: "sandbox",
81        permission_set: "sandbox/remote-execute",
82        kind: RemoteBindingKind::Sandbox,
83        description:
84            "Create and terminate sandboxes in this sandbox resource, and run arbitrary code inside them",
85        // A sandbox's parent is the MicroVM image its own emitter builds, and an open-egress
86        // sandbox attaches no VPC connector, so setup owes this binding no other resource.
87        setup_support_resource_types: &[],
88        revision: 1,
89    },
90];
91
92pub fn remote_binding_definition(
93    resource_type: &ResourceType,
94) -> Option<&'static RemoteBindingDefinition> {
95    DEFINITIONS
96        .iter()
97        .find(|definition| definition.resource_type == resource_type.as_ref())
98}
99
100/// A grant is attached by the setup artifact, so only a resource it renders something for can
101/// be published: every Frozen one, and the Live sandbox through its scaffolding.
102pub fn remote_binding_for_entry(entry: &ResourceEntry) -> Option<&'static RemoteBindingDefinition> {
103    let resource_type = entry.config.resource_type();
104    (entry.remote_access
105        && ownership_policy_for_resource_type(resource_type.as_ref())
106            .emits_setup_scaffolding(entry.lifecycle))
107    .then(|| remote_binding_definition(&resource_type))
108    .flatten()
109}
110
111/// Why a declaration's remote binding is one a deployment cannot deliver, if it cannot.
112///
113/// Two cases, both sandbox-only and both about a declared policy the remote grant cannot carry.
114///
115/// **Egress.** The same refusal on every cloud that publishes a sandbox remotely, for mechanisms
116/// that are worth telling apart. On AWS the declared connector is *unreachable*: starting a
117/// sandbox is additionally authorized as `lambda:PassNetworkConnector` and the remote grant
118/// passes only AWS's own connectors. On Azure it is *bypassable*: the grant is the
119/// `SandboxGroup Data Owner` data-plane role, so its holder creates sandboxes against the group
120/// directly and the provider that would have applied the declared policy never runs. The Azure
121/// case is the security-relevant one — it is inherent to handing out a data-plane role, not a
122/// gap in an implementation that could later close it. On GCP the policy lives on the environment
123/// template, which the remote grant carries no verb to create or replace.
124///
125/// **Preview ports.** AWS's `CreateMicrovmAuthToken` has no port condition key, so a declared
126/// list bounds a caller going through the provider but not a holder of the leased credentials —
127/// a bound that only looks like one. On Azure and GCP this branch is unreachable rather than
128/// merely unused: `preview` is false for both, so `validate_capabilities` refuses a non-empty
129/// list at plan time before a stack gets this far.
130///
131/// Preflight refuses either; emitters and generated docs read this so nothing advertises a grant
132/// that cannot be used.
133pub fn remote_binding_undeliverable_reason(entry: &ResourceEntry) -> Option<&'static str> {
134    remote_binding_for_entry(entry)?;
135    let sandbox = entry.config.downcast_ref::<Sandbox>()?;
136
137    if sandbox.privileged_supervisor.is_some() {
138        return Some("a remotely published sandbox cannot declare privilegedSupervisor; the raw grant can start retained image versions with a different command identity or egress policy; use an ordinary workload binding");
139    }
140
141    if !matches!(sandbox.egress, SandboxEgress::Allow) {
142        return Some(
143            "a remotely published sandbox must declare egress 'allow'; the remote grant either \
144             cannot pass a declared connector or lets its holder create sandboxes that ignore the \
145             declared policy, so the declaration would not bound the remote caller",
146        );
147    }
148
149    if !sandbox.preview_ports.is_empty() {
150        return Some(
151            "a remotely published sandbox must declare no previewPorts; the sandbox token mint \
152             carries no port condition, so the list bounds a caller reaching the sandbox through \
153             its binding but not a holder of the remote credentials",
154        );
155    }
156
157    None
158}
159
160/// Whether a declaration's remote binding is one a deployment can actually deliver.
161pub fn remote_binding_is_deliverable(entry: &ResourceEntry) -> bool {
162    remote_binding_undeliverable_reason(entry).is_none()
163}
164
165/// Whether a stack's remote bindings mean this global management set belongs to the caller's
166/// identity rather than the deployment's.
167///
168/// The binding's own set always does. A sandbox binding additionally claims anything that reaches
169/// a sandbox, because the remote caller drives those; `reaches_a_sandbox` decides that, so the
170/// permission registry stays the single place the verbs are named.
171pub fn remote_binding_claims_management_set<'a>(
172    resources: impl IntoIterator<Item = &'a ResourceEntry>,
173    permission_set_id: &str,
174    reaches_a_sandbox: impl Fn() -> bool,
175) -> bool {
176    resources.into_iter().any(|entry| {
177        remote_binding_for_entry(entry).is_some_and(|definition| {
178            permission_set_id == definition.permission_set
179                || (definition.kind == RemoteBindingKind::Sandbox && reaches_a_sandbox())
180        })
181    })
182}
183
184pub fn remote_binding_definitions() -> &'static [RemoteBindingDefinition] {
185    DEFINITIONS
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::{ResourceLifecycle, Sandbox, SandboxCode, SandboxLifecyclePolicy, SandboxLimits};
192
193    fn remote_sandbox(egress: SandboxEgress, preview_ports: Vec<u16>) -> ResourceEntry {
194        let sandbox = Sandbox::new("agent-sbx".to_string())
195            .code(SandboxCode::Image {
196                image: "ubuntu".to_string(),
197            })
198            .limits(SandboxLimits {
199                cpu: "1".to_string(),
200                memory: "2Gi".to_string(),
201                disk: "20Gi".to_string(),
202                max_processes: None,
203            })
204            .egress(egress)
205            .lifecycle(SandboxLifecyclePolicy {
206                max_lifetime_seconds: None,
207                idle_pause_seconds: None,
208            })
209            .preview_ports(preview_ports)
210            .build();
211
212        ResourceEntry {
213            enabled_when: None,
214            config: crate::Resource::new(sandbox),
215            dependencies: Vec::new(),
216            lifecycle: ResourceLifecycle::Frozen,
217            remote_access: true,
218        }
219    }
220
221    #[test]
222    fn a_remote_grant_cannot_bypass_supervision_through_a_retained_version() {
223        for egress in [
224            SandboxEgress::Allow,
225            SandboxEgress::Deny,
226            SandboxEgress::AllowDomains {
227                domains: vec!["example.com".to_string()],
228            },
229        ] {
230            let mut entry = remote_sandbox(egress, vec![]);
231            let mut sandbox = entry
232                .config
233                .downcast_ref::<Sandbox>()
234                .expect("sandbox")
235                .clone();
236            sandbox.privileged_supervisor =
237                Some(crate::SandboxPrivilegedSupervisor { command_uid: 60001 });
238            entry.config = crate::Resource::new(sandbox);
239            assert!(remote_binding_undeliverable_reason(&entry)
240                .expect("raw version-wide grant is unsafe")
241                .contains("privilegedSupervisor"));
242            entry.remote_access = false;
243            assert_eq!(
244                remote_binding_undeliverable_reason(&entry),
245                None,
246                "ordinary bindings select the active version"
247            );
248        }
249    }
250
251    /// Every deployment today declares no ports; a refusal that caught them would be the worst
252    /// outcome of adding one.
253    #[test]
254    fn a_remote_sandbox_declaring_no_ports_is_deliverable() {
255        assert!(remote_binding_is_deliverable(&remote_sandbox(
256            SandboxEgress::Allow,
257            Vec::new()
258        )));
259    }
260
261    /// The mint carries no port condition key, so the list bounds a caller reaching the sandbox
262    /// through its binding and not a holder of the leased credentials.
263    #[test]
264    fn a_remote_sandbox_declaring_ports_is_refused() {
265        let reason =
266            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
267                .expect("a declared port list is not deliverable to a remote caller");
268
269        assert!(
270            reason.contains("previewPorts"),
271            "the refusal must name the field the user declared"
272        );
273    }
274
275    /// The question only applies to a remote binding. A deployment's own compute reaching its own
276    /// sandbox is not this problem, and refusing it would be a false positive.
277    #[test]
278    fn a_sandbox_with_no_remote_binding_may_declare_ports() {
279        let mut entry = remote_sandbox(SandboxEgress::Allow, vec![8080]);
280        entry.remote_access = false;
281
282        assert_eq!(remote_binding_undeliverable_reason(&entry), None);
283        assert!(remote_binding_is_deliverable(&entry));
284    }
285
286    /// Two undeliverable declarations, two reasons. Collapsing them would answer a port mistake
287    /// with an egress instruction.
288    #[test]
289    fn each_undeliverable_declaration_answers_in_its_own_terms() {
290        let egress =
291            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Deny, Vec::new()))
292                .expect("a restricted egress is not deliverable");
293        let ports =
294            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
295                .expect("a declared port list is not deliverable");
296
297        assert_ne!(egress, ports, "one reason cannot stand in for the other");
298        assert!(egress.contains("egress"));
299    }
300}