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