Skip to main content

alien_core/
remote_bindings.rs

1use crate::{ResourceEntry, ResourceLifecycle, ResourceType, Sandbox, SandboxEgress};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum RemoteBindingKind {
5    Storage,
6    Key,
7    Ai,
8    Sandbox,
9}
10
11/// One resource type's provider-neutral Remote Bindings contract.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct RemoteBindingDefinition {
14    pub resource_type: &'static str,
15    pub permission_set: &'static str,
16    pub kind: RemoteBindingKind,
17    pub description: &'static str,
18    /// Setup-owned parent resources that this binding kind may require. They do not turn a
19    /// bindings-only stack into an application stack.
20    pub setup_support_resource_types: &'static [&'static str],
21    /// Increment when the permission set's effective grants change. This makes direct setup
22    /// updates reconcile permissions even when the application resource config is unchanged.
23    pub revision: u32,
24}
25
26const DEFINITIONS: &[RemoteBindingDefinition] = &[
27    RemoteBindingDefinition {
28        resource_type: "storage",
29        permission_set: "storage/remote-data-write",
30        kind: RemoteBindingKind::Storage,
31        description: "Read and write objects in this storage resource",
32        setup_support_resource_types: &[
33            "azure_resource_group",
34            "azure_storage_account",
35            "service_activation",
36        ],
37        revision: 1,
38    },
39    RemoteBindingDefinition {
40        resource_type: "key",
41        permission_set: "key/remote-cryptography",
42        kind: RemoteBindingKind::Key,
43        description: "Encrypt and decrypt small values with this key",
44        setup_support_resource_types: &["azure_resource_group", "service_activation"],
45        revision: 1,
46    },
47    RemoteBindingDefinition {
48        resource_type: "ai",
49        permission_set: "ai/invoke",
50        kind: RemoteBindingKind::Ai,
51        description: "Invoke models through this AI resource",
52        setup_support_resource_types: &["azure_resource_group", "service_activation"],
53        revision: 1,
54    },
55    RemoteBindingDefinition {
56        resource_type: "sandbox",
57        permission_set: "sandbox/remote-execute",
58        kind: RemoteBindingKind::Sandbox,
59        description:
60            "Create and terminate sessions in this sandbox, and run arbitrary code inside them",
61        // A sandbox's parent is the MicroVM image its own emitter builds, and an open-egress
62        // sandbox attaches no VPC connector, so setup owes this binding no other resource.
63        setup_support_resource_types: &[],
64        revision: 1,
65    },
66];
67
68pub fn remote_binding_definition(
69    resource_type: &ResourceType,
70) -> Option<&'static RemoteBindingDefinition> {
71    DEFINITIONS
72        .iter()
73        .find(|definition| definition.resource_type == resource_type.as_ref())
74}
75
76pub fn remote_binding_for_entry(entry: &ResourceEntry) -> Option<&'static RemoteBindingDefinition> {
77    (entry.remote_access && entry.lifecycle == ResourceLifecycle::Frozen)
78        .then(|| remote_binding_definition(&entry.config.resource_type()))
79        .flatten()
80}
81
82/// Why a declaration's remote binding is one a deployment cannot deliver, if it cannot.
83///
84/// Two cases, both sandbox-only and both about a declared policy the remote grant cannot carry.
85/// Egress: starting a session is additionally authorized as `lambda:PassNetworkConnector`, and the
86/// remote grant passes only AWS's own connectors, so a customer-declared one is unreachable.
87/// Preview ports: `CreateMicrovmAuthToken` has no port condition key, so the list bounds a caller
88/// going through the provider but not a holder of the leased credentials — a bound that only looks
89/// like one. Preflight refuses either; emitters and generated docs read this so nothing advertises
90/// a grant that cannot be used.
91pub fn remote_binding_undeliverable_reason(entry: &ResourceEntry) -> Option<&'static str> {
92    remote_binding_for_entry(entry)?;
93    let sandbox = entry.config.downcast_ref::<Sandbox>()?;
94
95    if !matches!(sandbox.egress, SandboxEgress::Allow) {
96        return Some(
97            "a remotely published sandbox must declare egress 'allow'; a sandbox that routes its \
98             traffic through an egress connector cannot be reached remotely",
99        );
100    }
101
102    if !sandbox.preview_ports.is_empty() {
103        return Some(
104            "a remotely published sandbox must declare no previewPorts; the session token mint \
105             carries no port condition, so the list bounds a caller reaching the sandbox through \
106             its binding but not a holder of the remote credentials",
107        );
108    }
109
110    None
111}
112
113/// Whether a declaration's remote binding is one a deployment can actually deliver.
114pub fn remote_binding_is_deliverable(entry: &ResourceEntry) -> bool {
115    remote_binding_undeliverable_reason(entry).is_none()
116}
117
118/// Whether a stack's remote bindings mean this global management set belongs to the caller's
119/// identity rather than the deployment's.
120///
121/// The binding's own set always does. A sandbox binding additionally claims anything that reaches
122/// a session, because the remote caller drives those; `reaches_a_session` decides that, so the
123/// permission registry stays the single place the verbs are named.
124pub fn remote_binding_claims_management_set<'a>(
125    resources: impl IntoIterator<Item = &'a ResourceEntry>,
126    permission_set_id: &str,
127    reaches_a_session: impl Fn() -> bool,
128) -> bool {
129    resources.into_iter().any(|entry| {
130        remote_binding_for_entry(entry).is_some_and(|definition| {
131            permission_set_id == definition.permission_set
132                || (definition.kind == RemoteBindingKind::Sandbox && reaches_a_session())
133        })
134    })
135}
136
137pub fn remote_binding_definitions() -> &'static [RemoteBindingDefinition] {
138    DEFINITIONS
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::{Sandbox, SandboxCode, SandboxLimits, SandboxSessionPolicy};
145
146    fn remote_sandbox(egress: SandboxEgress, preview_ports: Vec<u16>) -> ResourceEntry {
147        let sandbox = Sandbox::new("agent-sbx".to_string())
148            .code(SandboxCode::Image {
149                image: "ubuntu".to_string(),
150            })
151            .limits(SandboxLimits {
152                cpu: "1".to_string(),
153                memory: "2Gi".to_string(),
154                disk: "20Gi".to_string(),
155                max_processes: None,
156            })
157            .egress(egress)
158            .session(SandboxSessionPolicy {
159                max_lifetime_seconds: None,
160                idle_suspend_seconds: None,
161            })
162            .preview_ports(preview_ports)
163            .build();
164
165        ResourceEntry {
166            enabled_when: None,
167            config: crate::Resource::new(sandbox),
168            dependencies: Vec::new(),
169            lifecycle: ResourceLifecycle::Frozen,
170            remote_access: true,
171        }
172    }
173
174    /// Every deployment today declares no ports; a refusal that caught them would be the worst
175    /// outcome of adding one.
176    #[test]
177    fn a_remote_sandbox_declaring_no_ports_is_deliverable() {
178        assert!(remote_binding_is_deliverable(&remote_sandbox(
179            SandboxEgress::Allow,
180            Vec::new()
181        )));
182    }
183
184    /// The mint carries no port condition key, so the list bounds a caller reaching the sandbox
185    /// through its binding and not a holder of the leased credentials.
186    #[test]
187    fn a_remote_sandbox_declaring_ports_is_refused() {
188        let reason =
189            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
190                .expect("a declared port list is not deliverable to a remote caller");
191
192        assert!(
193            reason.contains("previewPorts"),
194            "the refusal must name the field the user declared"
195        );
196    }
197
198    /// The question only applies to a remote binding. A deployment's own compute reaching its own
199    /// sandbox is not this problem, and refusing it would be a false positive.
200    #[test]
201    fn a_sandbox_with_no_remote_binding_may_declare_ports() {
202        let mut entry = remote_sandbox(SandboxEgress::Allow, vec![8080]);
203        entry.remote_access = false;
204
205        assert_eq!(remote_binding_undeliverable_reason(&entry), None);
206        assert!(remote_binding_is_deliverable(&entry));
207    }
208
209    /// Two undeliverable declarations, two reasons. Collapsing them would answer a port mistake
210    /// with an egress instruction.
211    #[test]
212    fn each_undeliverable_declaration_answers_in_its_own_terms() {
213        let egress =
214            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Deny, Vec::new()))
215                .expect("a restricted egress is not deliverable");
216        let ports =
217            remote_binding_undeliverable_reason(&remote_sandbox(SandboxEgress::Allow, vec![8080]))
218                .expect("a declared port list is not deliverable");
219
220        assert_ne!(egress, ports, "one reason cannot stand in for the other");
221        assert!(egress.contains("egress"));
222    }
223}