Skip to main content

fakecloud_shield/
service.rs

1//! AWS Shield awsJson1_1 dispatch + operation handlers.
2//!
3//! Requests carry `X-Amz-Target: AWSShield_20160616.<Operation>`; dispatch keys
4//! off `req.action`. Every operation runs model-driven input validation first
5//! (required / length / range / enum), then real, account-partitioned,
6//! persisted CRUD. Errors are AWS-shaped and drawn from each operation's
7//! declared Smithy error set (`ResourceNotFoundException`,
8//! `InvalidParameterException`, `ResourceAlreadyExistsException`,
9//! `OptimisticLockException`, `LockedSubscriptionException`,
10//! `InvalidResourceException`, `LimitsExceededException`,
11//! `NoAssociatedRoleException`, ...).
12
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use http::StatusCode;
17use serde_json::{json, Value};
18use tokio::sync::Mutex as AsyncMutex;
19
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
21use fakecloud_persistence::SnapshotStore;
22
23use crate::state::{SharedShieldState, ShieldState};
24
25pub const SHIELD_ACTIONS: &[&str] = &[
26    "AssociateDRTLogBucket",
27    "AssociateDRTRole",
28    "AssociateHealthCheck",
29    "AssociateProactiveEngagementDetails",
30    "CreateProtection",
31    "CreateProtectionGroup",
32    "CreateSubscription",
33    "DeleteProtection",
34    "DeleteProtectionGroup",
35    "DeleteSubscription",
36    "DescribeAttack",
37    "DescribeAttackStatistics",
38    "DescribeDRTAccess",
39    "DescribeEmergencyContactSettings",
40    "DescribeProtection",
41    "DescribeProtectionGroup",
42    "DescribeSubscription",
43    "DisableApplicationLayerAutomaticResponse",
44    "DisableProactiveEngagement",
45    "DisassociateDRTLogBucket",
46    "DisassociateDRTRole",
47    "DisassociateHealthCheck",
48    "EnableApplicationLayerAutomaticResponse",
49    "EnableProactiveEngagement",
50    "GetSubscriptionState",
51    "ListAttacks",
52    "ListProtectionGroups",
53    "ListProtections",
54    "ListResourcesInProtectionGroup",
55    "ListTagsForResource",
56    "TagResource",
57    "UntagResource",
58    "UpdateApplicationLayerAutomaticResponse",
59    "UpdateEmergencyContactSettings",
60    "UpdateProtectionGroup",
61    "UpdateSubscription",
62];
63
64/// Read-only verbs; any other action mutates persisted state and triggers a
65/// snapshot after success. The inverse formulation guarantees no mutation is
66/// ever missed if a new op is added.
67fn is_mutating_action(action: &str) -> bool {
68    const READ_PREFIXES: &[&str] = &["Get", "List", "Describe"];
69    !READ_PREFIXES.iter().any(|p| action.starts_with(p))
70}
71
72pub struct ShieldService {
73    state: SharedShieldState,
74    snapshot_store: Option<Arc<dyn SnapshotStore>>,
75    snapshot_lock: Arc<AsyncMutex<()>>,
76}
77
78impl ShieldService {
79    pub fn new(state: SharedShieldState) -> Self {
80        Self {
81            state,
82            snapshot_store: None,
83            snapshot_lock: Arc::new(AsyncMutex::new(())),
84        }
85    }
86
87    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
88        self.snapshot_store = Some(store);
89        self
90    }
91
92    async fn save(&self) {
93        crate::persistence::save_snapshot(
94            &self.state,
95            self.snapshot_store.clone(),
96            &self.snapshot_lock,
97        )
98        .await;
99    }
100
101    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
102    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
103        let store = self.snapshot_store.clone()?;
104        let state = self.state.clone();
105        let lock = self.snapshot_lock.clone();
106        Some(Arc::new(move || {
107            let state = state.clone();
108            let store = store.clone();
109            let lock = lock.clone();
110            Box::pin(async move {
111                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
112            })
113        }))
114    }
115}
116
117#[async_trait]
118impl AwsService for ShieldService {
119    fn service_name(&self) -> &str {
120        "shield"
121    }
122
123    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
124        let action = req.action.clone();
125        // Model-driven input validation (required/length/range/enum) runs
126        // before any handler, mirroring AWS's request-validation phase.
127        if let Err(msg) = crate::validate::validate_input(&action, &req.json_body()) {
128            return Err(invalid_parameter(msg));
129        }
130        let result = self.dispatch(&action, &req);
131        if is_mutating_action(&action)
132            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
133        {
134            self.save().await;
135        }
136        result
137    }
138
139    fn supported_actions(&self) -> &[&str] {
140        SHIELD_ACTIONS
141    }
142}
143
144impl ShieldService {
145    #[allow(clippy::too_many_lines)]
146    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
147        match action {
148            // Protections
149            "CreateProtection" => self.create_protection(req),
150            "DescribeProtection" => self.describe_protection(req),
151            "DeleteProtection" => self.delete_protection(req),
152            "ListProtections" => self.list_protections(req),
153            // Protection groups
154            "CreateProtectionGroup" => self.create_protection_group(req),
155            "UpdateProtectionGroup" => self.update_protection_group(req),
156            "DeleteProtectionGroup" => self.delete_protection_group(req),
157            "DescribeProtectionGroup" => self.describe_protection_group(req),
158            "ListProtectionGroups" => self.list_protection_groups(req),
159            "ListResourcesInProtectionGroup" => self.list_resources_in_protection_group(req),
160            // Subscription
161            "CreateSubscription" => self.create_subscription(req),
162            "DescribeSubscription" => self.describe_subscription(req),
163            "UpdateSubscription" => self.update_subscription(req),
164            "DeleteSubscription" => self.delete_subscription(req),
165            "GetSubscriptionState" => self.get_subscription_state(req),
166            // Attacks (honest empty; no synthetic DDoS records)
167            "ListAttacks" => self.list_attacks(req),
168            "DescribeAttack" => self.describe_attack(req),
169            "DescribeAttackStatistics" => self.describe_attack_statistics(req),
170            // DRT access
171            "AssociateDRTRole" => self.associate_drt_role(req),
172            "DisassociateDRTRole" => self.disassociate_drt_role(req),
173            "AssociateDRTLogBucket" => self.associate_drt_log_bucket(req),
174            "DisassociateDRTLogBucket" => self.disassociate_drt_log_bucket(req),
175            "DescribeDRTAccess" => self.describe_drt_access(req),
176            // Health checks
177            "AssociateHealthCheck" => self.associate_health_check(req),
178            "DisassociateHealthCheck" => self.disassociate_health_check(req),
179            // Proactive engagement + emergency contacts
180            "AssociateProactiveEngagementDetails" => {
181                self.associate_proactive_engagement_details(req)
182            }
183            "EnableProactiveEngagement" => self.enable_proactive_engagement(req),
184            "DisableProactiveEngagement" => self.disable_proactive_engagement(req),
185            "UpdateEmergencyContactSettings" => self.update_emergency_contact_settings(req),
186            "DescribeEmergencyContactSettings" => self.describe_emergency_contact_settings(req),
187            // Application-layer automatic response
188            "EnableApplicationLayerAutomaticResponse" => {
189                self.enable_application_layer_automatic_response(req)
190            }
191            "UpdateApplicationLayerAutomaticResponse" => {
192                self.update_application_layer_automatic_response(req)
193            }
194            "DisableApplicationLayerAutomaticResponse" => {
195                self.disable_application_layer_automatic_response(req)
196            }
197            // Tags
198            "TagResource" => self.tag_resource(req),
199            "UntagResource" => self.untag_resource(req),
200            "ListTagsForResource" => self.list_tags_for_resource(req),
201            _ => Err(AwsServiceError::action_not_implemented(
202                self.service_name(),
203                action,
204            )),
205        }
206    }
207}
208
209// ---- shared error + helper constructors -----------------------------------
210
211fn err(code: &str, msg: impl Into<String>) -> AwsServiceError {
212    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg.into())
213}
214
215pub(crate) fn invalid_parameter(msg: impl Into<String>) -> AwsServiceError {
216    err("InvalidParameterException", msg)
217}
218
219pub(crate) fn resource_not_found(msg: impl Into<String>) -> AwsServiceError {
220    err("ResourceNotFoundException", msg)
221}
222
223pub(crate) fn resource_already_exists(msg: impl Into<String>) -> AwsServiceError {
224    err("ResourceAlreadyExistsException", msg)
225}
226
227pub(crate) fn invalid_resource(msg: impl Into<String>) -> AwsServiceError {
228    err("InvalidResourceException", msg)
229}
230
231pub(crate) fn limits_exceeded(msg: impl Into<String>) -> AwsServiceError {
232    err("LimitsExceededException", msg)
233}
234
235pub(crate) fn no_associated_role(msg: impl Into<String>) -> AwsServiceError {
236    err("NoAssociatedRoleException", msg)
237}
238
239pub(crate) fn locked_subscription(msg: impl Into<String>) -> AwsServiceError {
240    err("LockedSubscriptionException", msg)
241}
242
243pub(crate) fn ok(value: Value) -> Result<AwsResponse, AwsServiceError> {
244    Ok(AwsResponse::ok_json(value))
245}
246
247/// Current time as epoch seconds (awsJson1_1 default timestamp wire form).
248pub(crate) fn now_epoch() -> f64 {
249    chrono::Utc::now().timestamp_millis() as f64 / 1000.0
250}
251
252/// A one-year Shield Advanced commitment, in seconds.
253pub(crate) const ONE_YEAR_SECS: f64 = 365.0 * 24.0 * 60.0 * 60.0;
254
255/// Shield is a global service: its ARNs carry an empty region field.
256pub(crate) fn protection_arn(account: &str, id: &str) -> String {
257    format!("arn:aws:shield::{account}:protection/{id}")
258}
259
260pub(crate) fn protection_group_arn(account: &str, id: &str) -> String {
261    format!("arn:aws:shield::{account}:protection-group/{id}")
262}
263
264pub(crate) fn subscription_arn(account: &str) -> String {
265    format!("arn:aws:shield::{account}:subscription")
266}
267
268impl ShieldService {
269    /// Run `f` against this account's mutable state.
270    pub(crate) fn with_account_mut<R>(
271        &self,
272        req: &AwsRequest,
273        f: impl FnOnce(&mut ShieldState) -> R,
274    ) -> R {
275        let mut guard = self.state.write();
276        let acct = guard.get_or_create(&req.account_id);
277        f(acct)
278    }
279
280    /// Run `f` against this account's read-only state (empty default if the
281    /// account has never been touched).
282    pub(crate) fn with_account<R>(&self, req: &AwsRequest, f: impl FnOnce(&ShieldState) -> R) -> R {
283        let guard = self.state.read();
284        match guard.get(&req.account_id) {
285            Some(acct) => f(acct),
286            None => f(&ShieldState::default()),
287        }
288    }
289}
290
291include!("handlers.rs");