Skip to main content

fakecloud_lambda/
resource_policy.rs

1//! Lambda implementation of [`ResourcePolicyProvider`].
2//!
3//! Lambda persists function resource policies as raw JSON in
4//! [`crate::state::LambdaFunction::policy`]. Both `AddPermission` and
5//! `RemovePermission` write through that field, seeding a canonical
6//! `{"Version":"2012-10-17","Statement":[...]}` document so the
7//! existing cross-service evaluator path reads it without a
8//! Lambda-specific fork. This file is the read-side bridge into the
9//! `fakecloud-core::auth::ResourcePolicyProvider` trait.
10//!
11//! Mirrors [`fakecloud_sns::resource_policy::SnsResourcePolicyProvider`]
12//! and [`fakecloud_s3::resource_policy::S3ResourcePolicyProvider`]:
13//! single-service gate, ARN parsing, state lookup, return `None` for
14//! anything not owned here so composition is safe.
15
16use std::sync::Arc;
17
18use fakecloud_core::auth::ResourcePolicyProvider;
19
20use crate::state::SharedLambdaState;
21
22/// Concrete [`ResourcePolicyProvider`] backed by the in-memory
23/// [`crate::state::LambdaState`]. Server bootstrap clone-shares it via
24/// [`fakecloud_core::auth::MultiResourcePolicyProvider`] alongside the
25/// S3 and SNS providers.
26pub struct LambdaResourcePolicyProvider {
27    state: SharedLambdaState,
28}
29
30impl LambdaResourcePolicyProvider {
31    pub fn new(state: SharedLambdaState) -> Self {
32        Self { state }
33    }
34
35    /// Convenience constructor returning an
36    /// `Arc<dyn ResourcePolicyProvider>` so bootstrap can push it
37    /// directly into a `MultiResourcePolicyProvider`.
38    pub fn shared(state: SharedLambdaState) -> Arc<dyn ResourcePolicyProvider> {
39        Arc::new(Self::new(state))
40    }
41}
42
43impl ResourcePolicyProvider for LambdaResourcePolicyProvider {
44    fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
45        if !service.eq_ignore_ascii_case("lambda") {
46            return None;
47        }
48        let function_name = parse_function_name(resource_arn)?;
49        // Extract account ID from ARN: arn:aws:lambda:REGION:ACCOUNT:function:NAME
50        let account_id = resource_arn.split(':').nth(4).unwrap_or("").to_string();
51        let accounts = self.state.read();
52        let state = accounts.get(&account_id)?;
53        state
54            .functions
55            .get(function_name)
56            .and_then(|f| f.policy.clone())
57    }
58}
59
60/// Extract the function name from a Lambda ARN of the form
61/// `arn:aws:lambda:REGION:ACCOUNT:function:NAME`. Qualified ARNs
62/// (`function:NAME:VERSION` or `function:NAME:ALIAS`) keep the bare
63/// function name — `LambdaState::functions` is keyed by unqualified
64/// name and resource policies are attached at the function level.
65///
66/// Returns `None` for anything that isn't a fully-qualified function
67/// ARN so the caller short-circuits to "no policy" rather than
68/// looking up stray map keys.
69fn parse_function_name(arn: &str) -> Option<&str> {
70    let rest = arn.strip_prefix("arn:aws:lambda:")?;
71    // arn:aws:lambda:REGION:ACCOUNT:function:NAME[:QUALIFIER]
72    // After the prefix: REGION:ACCOUNT:function:NAME[:QUALIFIER]
73    let parts: Vec<&str> = rest.split(':').collect();
74    // Expect at least 4 segments: region, account, "function", name.
75    if parts.len() < 4 {
76        return None;
77    }
78    let region = parts[0];
79    let account = parts[1];
80    let resource_type = parts[2];
81    let name = parts[3];
82    if region.is_empty() || account.is_empty() {
83        return None;
84    }
85    if resource_type != "function" {
86        return None;
87    }
88    if name.is_empty() {
89        return None;
90    }
91    Some(name)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::state::{LambdaFunction, LambdaState};
98    use chrono::Utc;
99    use fakecloud_aws::arn::Arn;
100    use parking_lot::RwLock;
101    use std::collections::BTreeMap;
102
103    fn func_with_policy(name: &str, policy: Option<&str>) -> LambdaFunction {
104        LambdaFunction {
105            function_name: name.to_string(),
106            function_arn: Arn::new(
107                "lambda",
108                "us-east-1",
109                "123456789012",
110                &format!("function:{name}"),
111            )
112            .to_string(),
113            runtime: "python3.12".to_string(),
114            role: "arn:aws:iam::123456789012:role/r".to_string(),
115            handler: "index.handler".to_string(),
116            description: String::new(),
117            timeout: 3,
118            memory_size: 128,
119            code_sha256: String::new(),
120            code_size: 0,
121            version: "$LATEST".to_string(),
122            last_modified: Utc::now(),
123            tags: BTreeMap::new(),
124            environment: BTreeMap::new(),
125            architectures: Vec::new(),
126            package_type: "Zip".to_string(),
127            code_zip: None,
128            image_uri: None,
129            policy: policy.map(str::to_string),
130            layers: Vec::new(),
131            revision_id: "test-rev".to_string(),
132            tracing_mode: None,
133            kms_key_arn: None,
134            ephemeral_storage_size: None,
135            vpc_config: None,
136            snap_start: None,
137            dead_letter_config_arn: None,
138            file_system_configs: Vec::new(),
139            logging_config: None,
140            image_config: None,
141            durable_config: None,
142            signing_profile_version_arn: None,
143            signing_job_arn: None,
144            runtime_version_config: None,
145            master_arn: None,
146            state_reason: None,
147            state_reason_code: None,
148            last_update_status_reason: None,
149            last_update_status_reason_code: None,
150        }
151    }
152
153    fn state_with(func: LambdaFunction) -> SharedLambdaState {
154        let mut mas: fakecloud_core::multi_account::MultiAccountState<LambdaState> =
155            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "");
156        mas.get_or_create("123456789012")
157            .functions
158            .insert(func.function_name.clone(), func);
159        Arc::new(RwLock::new(mas))
160    }
161
162    #[test]
163    fn parse_function_name_accepts_valid_arn() {
164        assert_eq!(
165            parse_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn"),
166            Some("my-fn")
167        );
168    }
169
170    #[test]
171    fn parse_function_name_accepts_qualified_arn() {
172        // Qualified ARN (version / alias) — function name is still
173        // segment 4, qualifier follows as segment 5 and we drop it.
174        assert_eq!(
175            parse_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn:PROD"),
176            Some("my-fn")
177        );
178        assert_eq!(
179            parse_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn:7"),
180            Some("my-fn")
181        );
182    }
183
184    #[test]
185    fn parse_function_name_rejects_malformed() {
186        assert_eq!(parse_function_name(""), None);
187        assert_eq!(parse_function_name("not-an-arn"), None);
188        assert_eq!(parse_function_name("arn:aws:lambda:"), None);
189        assert_eq!(parse_function_name("arn:aws:lambda:us-east-1"), None);
190        assert_eq!(
191            parse_function_name("arn:aws:lambda:us-east-1:123456789012"),
192            None
193        );
194        // Event source mapping ARN — wrong resource type.
195        assert_eq!(
196            parse_function_name("arn:aws:lambda:us-east-1:123456789012:event-source-mapping:uuid"),
197            None
198        );
199        // Blank region or account.
200        assert_eq!(
201            parse_function_name("arn:aws:lambda::123456789012:function:f"),
202            None
203        );
204        assert_eq!(
205            parse_function_name("arn:aws:lambda:us-east-1::function:f"),
206            None
207        );
208        // Blank function name.
209        assert_eq!(
210            parse_function_name("arn:aws:lambda:us-east-1:123456789012:function:"),
211            None
212        );
213        // S3-shaped ARN.
214        assert_eq!(parse_function_name("arn:aws:s3:::my-bucket"), None);
215    }
216
217    #[test]
218    fn returns_stored_policy_for_lambda_arn() {
219        let doc = r#"{"Version":"2012-10-17","Statement":[]}"#;
220        let state = state_with(func_with_policy("my-fn", Some(doc)));
221        let provider = LambdaResourcePolicyProvider::new(state);
222        assert_eq!(
223            provider.resource_policy(
224                "lambda",
225                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
226            ),
227            Some(doc.to_string())
228        );
229    }
230
231    #[test]
232    fn qualified_arn_resolves_to_unqualified_function_policy() {
233        // Resource policies live on the function, not on specific
234        // version aliases. A qualified ARN must still resolve to the
235        // same stored document.
236        let doc = r#"{"Statement":[]}"#;
237        let state = state_with(func_with_policy("my-fn", Some(doc)));
238        let provider = LambdaResourcePolicyProvider::new(state);
239        assert_eq!(
240            provider.resource_policy(
241                "lambda",
242                "arn:aws:lambda:us-east-1:123456789012:function:my-fn:PROD"
243            ),
244            Some(doc.to_string())
245        );
246    }
247
248    #[test]
249    fn returns_none_when_function_has_no_policy() {
250        let state = state_with(func_with_policy("my-fn", None));
251        let provider = LambdaResourcePolicyProvider::new(state);
252        assert_eq!(
253            provider.resource_policy(
254                "lambda",
255                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
256            ),
257            None
258        );
259    }
260
261    #[test]
262    fn returns_none_when_function_missing() {
263        let state = state_with(func_with_policy("other", Some("{}")));
264        let provider = LambdaResourcePolicyProvider::new(state);
265        assert_eq!(
266            provider.resource_policy(
267                "lambda",
268                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
269            ),
270            None
271        );
272    }
273
274    #[test]
275    fn returns_none_for_non_lambda_service_prefix() {
276        let state = state_with(func_with_policy("my-fn", Some("{}")));
277        let provider = LambdaResourcePolicyProvider::new(state);
278        assert_eq!(
279            provider.resource_policy("s3", "arn:aws:lambda:us-east-1:123456789012:function:my-fn"),
280            None
281        );
282        assert_eq!(
283            provider.resource_policy(
284                "sns",
285                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
286            ),
287            None
288        );
289    }
290
291    #[test]
292    fn service_prefix_match_is_case_insensitive() {
293        let state = state_with(func_with_policy("my-fn", Some("{}")));
294        let provider = LambdaResourcePolicyProvider::new(state);
295        assert!(provider
296            .resource_policy(
297                "LAMBDA",
298                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
299            )
300            .is_some());
301    }
302
303    #[test]
304    fn shared_constructor_wraps_in_arc() {
305        let state = state_with(func_with_policy("my-fn", Some("doc")));
306        let arc = LambdaResourcePolicyProvider::shared(state);
307        assert_eq!(
308            arc.resource_policy(
309                "lambda",
310                "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
311            )
312            .as_deref(),
313            Some("doc")
314        );
315    }
316}