Skip to main content

fakecloud_ram/
service.rs

1//! AWS Resource Access Manager (RAM) restJson1 service handler.
2//!
3//! Implements the RAM control plane: resource shares plus their resource and
4//! principal associations and attached permissions, resource-share invitations
5//! (created when a share adds an external account principal), customer-managed
6//! permissions with their versions, tags, and the read paths over a seeded
7//! catalogue of AWS-managed default permissions and supported resource types.
8//!
9//! Every RAM operation is a single-segment restJson1 POST/DELETE (`@http`
10//! `POST /createresourceshare`, ...) with a JSON body, so routing keys purely
11//! on `(method, path)`. Associations progress synthetically to `ASSOCIATED` on
12//! create, mirroring the eventual-consistency of the real service.
13
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use http::{Method, StatusCode};
17use serde_json::{json, Map, Value};
18use std::sync::Arc;
19use tokio::sync::Mutex as AsyncMutex;
20
21use fakecloud_core::pagination::paginate_checked;
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::state::{
26    invitation_arn, managed_permission_arn, permission_arn, resource_share_arn, AssociationRecord,
27    InvitationRecord, PermissionRecord, PermissionVersionRecord, ResourceShareRecord,
28    SharedRamState, TagMap,
29};
30use crate::validate;
31
32pub const RAM_ACTIONS: &[&str] = &[
33    "AcceptResourceShareInvitation",
34    "AssociateResourceShare",
35    "AssociateResourceSharePermission",
36    "CreatePermission",
37    "CreatePermissionVersion",
38    "CreateResourceShare",
39    "DeletePermission",
40    "DeletePermissionVersion",
41    "DeleteResourceShare",
42    "DisassociateResourceShare",
43    "DisassociateResourceSharePermission",
44    "EnableSharingWithAwsOrganization",
45    "GetPermission",
46    "GetResourcePolicies",
47    "GetResourceShareAssociations",
48    "GetResourceShareInvitations",
49    "GetResourceShares",
50    "ListPendingInvitationResources",
51    "ListPermissionAssociations",
52    "ListPermissionVersions",
53    "ListPermissions",
54    "ListPrincipals",
55    "ListReplacePermissionAssociationsWork",
56    "ListResourceSharePermissions",
57    "ListResourceTypes",
58    "ListResources",
59    "ListSourceAssociations",
60    "PromotePermissionCreatedFromPolicy",
61    "PromoteResourceShareCreatedFromPolicy",
62    "RejectResourceShareInvitation",
63    "ReplacePermissionAssociations",
64    "SetDefaultPermissionVersion",
65    "TagResource",
66    "UntagResource",
67    "UpdateResourceShare",
68];
69
70const MUTATING: &[&str] = &[
71    "AcceptResourceShareInvitation",
72    "AssociateResourceShare",
73    "AssociateResourceSharePermission",
74    "CreatePermission",
75    "CreatePermissionVersion",
76    "CreateResourceShare",
77    "DeletePermission",
78    "DeletePermissionVersion",
79    "DeleteResourceShare",
80    "DisassociateResourceShare",
81    "DisassociateResourceSharePermission",
82    // EnableSharingWithAwsOrganization is intentionally NOT listed: it records
83    // no state (just returns `{returnValue:true}`), so snapshotting it would
84    // clone + persist the whole store on a no-op.
85    "PromotePermissionCreatedFromPolicy",
86    "PromoteResourceShareCreatedFromPolicy",
87    "RejectResourceShareInvitation",
88    "ReplacePermissionAssociations",
89    "SetDefaultPermissionVersion",
90    "TagResource",
91    "UntagResource",
92    "UpdateResourceShare",
93];
94
95/// AWS-managed default permissions seeded for the read paths: `(name, resourceType)`.
96const MANAGED_PERMISSIONS: &[(&str, &str)] = &[
97    ("AWSRAMDefaultPermissionSubnet", "ec2:Subnet"),
98    (
99        "AWSRAMDefaultPermissionTransitGateway",
100        "ec2:TransitGateway",
101    ),
102    (
103        "AWSRAMDefaultPermissionResolverRule",
104        "route53resolver:ResolverRule",
105    ),
106    ("AWSRAMDefaultPermissionPrefixList", "ec2:PrefixList"),
107    (
108        "AWSRAMDefaultPermissionLicenseConfiguration",
109        "license-manager:LicenseConfiguration",
110    ),
111];
112
113/// Resource types RAM can share: `(resourceType, serviceName, resourceRegionScope)`.
114const RESOURCE_TYPES: &[(&str, &str, &str)] = &[
115    ("ec2:Subnet", "ec2", "REGIONAL"),
116    ("ec2:TransitGateway", "ec2", "REGIONAL"),
117    ("ec2:PrefixList", "ec2", "REGIONAL"),
118    ("ec2:LocalGatewayRouteTable", "ec2", "REGIONAL"),
119    (
120        "route53resolver:ResolverRule",
121        "route53resolver",
122        "REGIONAL",
123    ),
124    (
125        "license-manager:LicenseConfiguration",
126        "license-manager",
127        "GLOBAL",
128    ),
129];
130
131pub struct RamService {
132    state: SharedRamState,
133    snapshot_store: Option<Arc<dyn SnapshotStore>>,
134    snapshot_lock: Arc<AsyncMutex<()>>,
135}
136
137impl RamService {
138    pub fn new(state: SharedRamState) -> Self {
139        Self {
140            state,
141            snapshot_store: None,
142            snapshot_lock: Arc::new(AsyncMutex::new(())),
143        }
144    }
145
146    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
147        self.snapshot_store = Some(store);
148        self
149    }
150
151    async fn save_snapshot(&self) {
152        crate::persistence::save_snapshot(
153            &self.state,
154            self.snapshot_store.clone(),
155            &self.snapshot_lock,
156        )
157        .await;
158    }
159
160    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
161    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
162        let store = self.snapshot_store.clone()?;
163        let state = self.state.clone();
164        let lock = self.snapshot_lock.clone();
165        Some(Arc::new(move || {
166            let state = state.clone();
167            let store = store.clone();
168            let lock = lock.clone();
169            Box::pin(async move {
170                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
171            })
172        }))
173    }
174
175    /// Route a request to an action name by method + single path segment.
176    fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
177        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
178        let seg = raw.strip_prefix('/').unwrap_or(raw);
179        let seg = seg.strip_suffix('/').unwrap_or(seg).to_ascii_lowercase();
180        let action = match (&req.method, seg.as_str()) {
181            (&Method::POST, "acceptresourceshareinvitation") => "AcceptResourceShareInvitation",
182            (&Method::POST, "associateresourceshare") => "AssociateResourceShare",
183            (&Method::POST, "associateresourcesharepermission") => {
184                "AssociateResourceSharePermission"
185            }
186            (&Method::POST, "createpermission") => "CreatePermission",
187            (&Method::POST, "createpermissionversion") => "CreatePermissionVersion",
188            (&Method::POST, "createresourceshare") => "CreateResourceShare",
189            (&Method::DELETE, "deletepermission") => "DeletePermission",
190            (&Method::DELETE, "deletepermissionversion") => "DeletePermissionVersion",
191            (&Method::DELETE, "deleteresourceshare") => "DeleteResourceShare",
192            (&Method::POST, "disassociateresourceshare") => "DisassociateResourceShare",
193            (&Method::POST, "disassociateresourcesharepermission") => {
194                "DisassociateResourceSharePermission"
195            }
196            (&Method::POST, "enablesharingwithawsorganization") => {
197                "EnableSharingWithAwsOrganization"
198            }
199            (&Method::POST, "getpermission") => "GetPermission",
200            (&Method::POST, "getresourcepolicies") => "GetResourcePolicies",
201            (&Method::POST, "getresourceshareassociations") => "GetResourceShareAssociations",
202            (&Method::POST, "getresourceshareinvitations") => "GetResourceShareInvitations",
203            (&Method::POST, "getresourceshares") => "GetResourceShares",
204            (&Method::POST, "listpendinginvitationresources") => "ListPendingInvitationResources",
205            (&Method::POST, "listpermissionassociations") => "ListPermissionAssociations",
206            (&Method::POST, "listpermissionversions") => "ListPermissionVersions",
207            (&Method::POST, "listpermissions") => "ListPermissions",
208            (&Method::POST, "listprincipals") => "ListPrincipals",
209            (&Method::POST, "listreplacepermissionassociationswork") => {
210                "ListReplacePermissionAssociationsWork"
211            }
212            (&Method::POST, "listresourcesharepermissions") => "ListResourceSharePermissions",
213            (&Method::POST, "listresourcetypes") => "ListResourceTypes",
214            (&Method::POST, "listresources") => "ListResources",
215            (&Method::POST, "listsourceassociations") => "ListSourceAssociations",
216            (&Method::POST, "promotepermissioncreatedfrompolicy") => {
217                "PromotePermissionCreatedFromPolicy"
218            }
219            (&Method::POST, "promoteresourcesharecreatedfrompolicy") => {
220                "PromoteResourceShareCreatedFromPolicy"
221            }
222            (&Method::POST, "rejectresourceshareinvitation") => "RejectResourceShareInvitation",
223            (&Method::POST, "replacepermissionassociations") => "ReplacePermissionAssociations",
224            (&Method::POST, "setdefaultpermissionversion") => "SetDefaultPermissionVersion",
225            (&Method::POST, "tagresource") => "TagResource",
226            (&Method::POST, "untagresource") => "UntagResource",
227            (&Method::POST, "updateresourceshare") => "UpdateResourceShare",
228            _ => return None,
229        };
230        Some(action)
231    }
232}
233
234#[async_trait]
235impl AwsService for RamService {
236    fn service_name(&self) -> &str {
237        "ram"
238    }
239
240    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
241        let action = Self::resolve_action(&req).ok_or_else(|| {
242            AwsServiceError::aws_error(
243                StatusCode::NOT_FOUND,
244                "UnknownOperationException",
245                format!("Unknown operation: {} {}", req.method, req.raw_path),
246            )
247        })?;
248
249        let result = self.dispatch(action, &req);
250
251        if MUTATING.contains(&action)
252            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
253        {
254            self.save_snapshot().await;
255        }
256        result
257    }
258
259    fn supported_actions(&self) -> &[&str] {
260        RAM_ACTIONS
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Free helpers
266// ---------------------------------------------------------------------------
267
268fn ok(v: Value) -> AwsResponse {
269    AwsResponse::json_value(StatusCode::OK, v)
270}
271
272fn ts(dt: DateTime<Utc>) -> Value {
273    json!(dt.timestamp_millis() as f64 / 1000.0)
274}
275
276fn gen_id() -> String {
277    uuid::Uuid::new_v4().to_string()
278}
279
280fn aws_err(code: &str, msg: impl Into<String>) -> AwsServiceError {
281    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
282}
283fn unknown_resource(msg: impl Into<String>) -> AwsServiceError {
284    aws_err("UnknownResourceException", msg)
285}
286fn invalid_param(msg: impl Into<String>) -> AwsServiceError {
287    aws_err("InvalidParameterException", msg)
288}
289fn invitation_not_found(msg: impl Into<String>) -> AwsServiceError {
290    aws_err("ResourceShareInvitationArnNotFoundException", msg)
291}
292
293fn req_body(req: &AwsRequest) -> Value {
294    serde_json::from_slice(&req.body).unwrap_or(Value::Null)
295}
296
297fn str_field(body: &Value, key: &str) -> Option<String> {
298    body.get(key).and_then(|v| v.as_str()).map(str::to_string)
299}
300
301/// Read an `@httpQuery`-bound parameter (used by the `Delete*` operations,
302/// which carry their identifiers in the query string rather than a body).
303fn query_str(req: &AwsRequest, key: &str) -> Option<String> {
304    req.query_params.get(key).cloned()
305}
306
307fn req_str(body: &Value, key: &str) -> Result<String, AwsServiceError> {
308    str_field(body, key).ok_or_else(|| invalid_param(format!("{key} is required.")))
309}
310
311fn bool_field(body: &Value, key: &str) -> Option<bool> {
312    body.get(key).and_then(|v| v.as_bool())
313}
314
315fn u32_field(body: &Value, key: &str) -> Option<u32> {
316    body.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
317}
318
319fn string_list(body: &Value, key: &str) -> Vec<String> {
320    body.get(key)
321        .and_then(|v| v.as_array())
322        .map(|a| {
323            a.iter()
324                .filter_map(|x| x.as_str().map(str::to_string))
325                .collect()
326        })
327        .unwrap_or_default()
328}
329
330/// Parse a `TagList` (`[{key,value}]`) into a map.
331fn parse_tag_list(body: &Value, key: &str) -> TagMap {
332    let mut out = TagMap::new();
333    if let Some(arr) = body.get(key).and_then(|v| v.as_array()) {
334        for t in arr {
335            if let (Some(k), Some(v)) = (
336                t.get("key").and_then(|x| x.as_str()),
337                t.get("value").and_then(|x| x.as_str()),
338            ) {
339                out.insert(k.to_string(), v.to_string());
340            }
341        }
342    }
343    out
344}
345
346fn tag_list_value(t: &TagMap) -> Value {
347    Value::Array(
348        t.iter()
349            .map(|(k, v)| json!({ "key": k, "value": v }))
350            .collect(),
351    )
352}
353
354/// Whether a share matches a `resourceOwner` filter for the calling account.
355/// `SELF` selects shares the account owns; `OTHER_ACCOUNTS` selects inbound
356/// shares owned by another account (materialised into the account's partition
357/// when its invitation was accepted).
358fn owner_matches(share: &ResourceShareRecord, owner: &str, account_id: &str) -> bool {
359    if owner == "SELF" {
360        share.owning_account_id == account_id
361    } else {
362        share.owning_account_id != account_id
363    }
364}
365
366/// Derive the RAM resource type (`<service>:<TypePascalCase>`, e.g.
367/// `ec2:Subnet`, `ec2:TransitGateway`) from a shared resource's ARN, matching
368/// the naming used by the seeded `ListResourceTypes` catalogue.
369fn ram_resource_type(arn: &str) -> String {
370    let parts: Vec<&str> = arn.split(':').collect();
371    if parts.len() >= 6 {
372        let service = parts[2];
373        let resource = parts[5..].join(":");
374        let kind = resource.split(['/', ':']).next().unwrap_or(&resource);
375        if !service.is_empty() && !kind.is_empty() {
376            let pascal: String = kind
377                .split('-')
378                .map(|w| {
379                    let mut c = w.chars();
380                    match c.next() {
381                        Some(f) => f.to_ascii_uppercase().to_string() + c.as_str(),
382                        None => String::new(),
383                    }
384                })
385                .collect();
386            return format!("{service}:{pascal}");
387        }
388    }
389    "unknown".to_string()
390}
391
392/// True when a principal id is an external AWS account (12-digit account id or
393/// an account/org ARN) rather than the caller's own account.
394fn is_external_principal(principal: &str, account_id: &str) -> bool {
395    if principal == account_id {
396        return false;
397    }
398    let is_account = principal.len() == 12 && principal.chars().all(|c| c.is_ascii_digit());
399    is_account || principal.starts_with("arn:aws:organizations::")
400}
401
402/// Numeric-offset pagination reading `maxResults`/`nextToken` from the JSON body.
403fn page_response(
404    items: Vec<Value>,
405    list_key: &str,
406    body: &Value,
407    extra: &[(&str, Value)],
408) -> Result<AwsResponse, AwsServiceError> {
409    let max = body
410        .get("maxResults")
411        .and_then(|v| v.as_u64())
412        .map(|n| n as usize)
413        .filter(|n| *n > 0)
414        .unwrap_or(500);
415    let token = body
416        .get("nextToken")
417        .and_then(|v| v.as_str())
418        .map(str::to_string);
419    let (page, next) = paginate_checked(&items, token.as_deref(), max)
420        .map_err(|_| aws_err("InvalidNextTokenException", "Invalid nextToken"))?;
421    let mut out = Map::new();
422    out.insert(list_key.to_string(), Value::Array(page));
423    if let Some(t) = next {
424        out.insert("nextToken".to_string(), Value::String(t));
425    }
426    for (k, v) in extra {
427        out.insert((*k).to_string(), v.clone());
428    }
429    Ok(ok(Value::Object(out)))
430}
431
432// ---------------------------------------------------------------------------
433// Value builders
434// ---------------------------------------------------------------------------
435
436fn share_to_value(r: &ResourceShareRecord) -> Value {
437    let mut m = Map::new();
438    m.insert("resourceShareArn".into(), json!(r.arn));
439    m.insert("name".into(), json!(r.name));
440    m.insert("owningAccountId".into(), json!(r.owning_account_id));
441    m.insert(
442        "allowExternalPrincipals".into(),
443        json!(r.allow_external_principals),
444    );
445    m.insert("status".into(), json!(r.status));
446    if let Some(msg) = &r.status_message {
447        m.insert("statusMessage".into(), json!(msg));
448    }
449    m.insert("tags".into(), tag_list_value(&r.tags));
450    m.insert("creationTime".into(), ts(r.creation_time));
451    m.insert("lastUpdatedTime".into(), ts(r.last_updated_time));
452    m.insert("featureSet".into(), json!(r.feature_set));
453    if let Some(retain) = r.retain_sharing {
454        m.insert(
455            "resourceShareConfiguration".into(),
456            json!({ "retainSharingOnAccountLeaveOrganization": retain }),
457        );
458    }
459    Value::Object(m)
460}
461
462fn association_to_value(arn: &str, name: &str, a: &AssociationRecord) -> Value {
463    let mut m = Map::new();
464    m.insert("resourceShareArn".into(), json!(arn));
465    m.insert("resourceShareName".into(), json!(name));
466    m.insert("associatedEntity".into(), json!(a.associated_entity));
467    m.insert("associationType".into(), json!(a.association_type));
468    m.insert("status".into(), json!(a.status));
469    if let Some(msg) = &a.status_message {
470        m.insert("statusMessage".into(), json!(msg));
471    }
472    m.insert("creationTime".into(), ts(a.creation_time));
473    m.insert("lastUpdatedTime".into(), ts(a.last_updated_time));
474    m.insert("external".into(), json!(a.external));
475    Value::Object(m)
476}
477
478fn invitation_to_value(inv: &InvitationRecord) -> Value {
479    json!({
480        "resourceShareInvitationArn": inv.arn,
481        "resourceShareName": inv.resource_share_name,
482        "resourceShareArn": inv.resource_share_arn,
483        "senderAccountId": inv.sender_account_id,
484        "receiverAccountId": inv.receiver_account_id,
485        "invitationTimestamp": ts(inv.invitation_timestamp),
486        "status": inv.status,
487    })
488}
489
490fn managed_summary(name: &str, resource_type: &str, now: DateTime<Utc>) -> Value {
491    json!({
492        "arn": managed_permission_arn(name),
493        "version": "1",
494        "defaultVersion": true,
495        "name": name,
496        "resourceType": resource_type,
497        "status": "ATTACHABLE",
498        "creationTime": ts(now),
499        "lastUpdatedTime": ts(now),
500        "isResourceTypeDefault": true,
501        "permissionType": "AWS_MANAGED",
502        "featureSet": "STANDARD",
503        "tags": [],
504    })
505}
506
507fn customer_summary(p: &PermissionRecord) -> Value {
508    json!({
509        "arn": p.arn,
510        "version": p.default_version.to_string(),
511        "defaultVersion": true,
512        "name": p.name,
513        "resourceType": p.resource_type,
514        "status": p.status,
515        "creationTime": ts(p.creation_time),
516        "lastUpdatedTime": ts(p.last_updated_time),
517        "isResourceTypeDefault": false,
518        "permissionType": "CUSTOMER_MANAGED",
519        "featureSet": p.feature_set,
520        "tags": tag_list_value(&p.tags),
521    })
522}
523
524fn customer_detail(p: &PermissionRecord, v: &PermissionVersionRecord) -> Value {
525    json!({
526        "arn": p.arn,
527        "version": v.version.to_string(),
528        "defaultVersion": v.is_default,
529        "name": p.name,
530        "resourceType": p.resource_type,
531        "permission": v.policy_template,
532        "creationTime": ts(v.creation_time),
533        "lastUpdatedTime": ts(v.last_updated_time),
534        "isResourceTypeDefault": false,
535        "permissionType": "CUSTOMER_MANAGED",
536        "featureSet": p.feature_set,
537        "status": p.status,
538        "tags": tag_list_value(&p.tags),
539    })
540}
541
542fn managed_detail(name: &str, resource_type: &str, now: DateTime<Utc>) -> Value {
543    let policy = json!({
544        "Effect": "Allow",
545        "Action": [format!("{}:*", resource_type.split(':').next().unwrap_or("*"))],
546    })
547    .to_string();
548    json!({
549        "arn": managed_permission_arn(name),
550        "version": "1",
551        "defaultVersion": true,
552        "name": name,
553        "resourceType": resource_type,
554        "permission": policy,
555        "creationTime": ts(now),
556        "lastUpdatedTime": ts(now),
557        "isResourceTypeDefault": true,
558        "permissionType": "AWS_MANAGED",
559        "featureSet": "STANDARD",
560        "status": "ATTACHABLE",
561        "tags": [],
562    })
563}
564
565fn find_managed(arn: &str) -> Option<(&'static str, &'static str)> {
566    MANAGED_PERMISSIONS
567        .iter()
568        .find(|(name, _)| managed_permission_arn(name) == arn)
569        .copied()
570}
571
572// ---------------------------------------------------------------------------
573// Dispatch + handlers
574// ---------------------------------------------------------------------------
575
576impl RamService {
577    #[allow(clippy::too_many_lines)]
578    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
579        validate::validate(action, req)?;
580        let body = req_body(req);
581        match action {
582            "CreateResourceShare" => self.create_resource_share(req, &body),
583            "UpdateResourceShare" => self.update_resource_share(req, &body),
584            "DeleteResourceShare" => self.delete_resource_share(req, &body),
585            "GetResourceShares" => self.get_resource_shares(req, &body),
586            "AssociateResourceShare" => self.associate_resource_share(req, &body, true),
587            "DisassociateResourceShare" => self.associate_resource_share(req, &body, false),
588            "AssociateResourceSharePermission" => self.associate_permission(req, &body, true),
589            "DisassociateResourceSharePermission" => self.associate_permission(req, &body, false),
590            "GetResourceShareAssociations" => self.get_share_associations(req, &body),
591            "GetResourceShareInvitations" => self.get_invitations(req, &body),
592            "AcceptResourceShareInvitation" => self.respond_invitation(req, &body, true),
593            "RejectResourceShareInvitation" => self.respond_invitation(req, &body, false),
594            "ListPendingInvitationResources" => self.list_pending_invitation_resources(req, &body),
595            "EnableSharingWithAwsOrganization" => Ok(ok(json!({ "returnValue": true }))),
596            "CreatePermission" => self.create_permission(req, &body),
597            "CreatePermissionVersion" => self.create_permission_version(req, &body),
598            "DeletePermission" => self.delete_permission(req, &body),
599            "DeletePermissionVersion" => self.delete_permission_version(req, &body),
600            "GetPermission" => self.get_permission(req, &body),
601            "ListPermissions" => self.list_permissions(req, &body),
602            "ListPermissionVersions" => self.list_permission_versions(req, &body),
603            "ListResourceSharePermissions" => self.list_resource_share_permissions(req, &body),
604            "SetDefaultPermissionVersion" => self.set_default_permission_version(req, &body),
605            "PromotePermissionCreatedFromPolicy" => self.promote_permission(req, &body),
606            "PromoteResourceShareCreatedFromPolicy" => self.promote_resource_share(req, &body),
607            "ReplacePermissionAssociations" => self.replace_permission_associations(req, &body),
608            "ListReplacePermissionAssociationsWork" => self.list_replace_work(req, &body),
609            "ListPermissionAssociations" => self.list_permission_associations(req, &body),
610            "ListPrincipals" => self.list_principals(req, &body),
611            "ListResources" => self.list_resources(req, &body),
612            "ListSourceAssociations" => page_response(Vec::new(), "sourceAssociations", &body, &[]),
613            "ListResourceTypes" => self.list_resource_types(req, &body),
614            "GetResourcePolicies" => self.get_resource_policies(req, &body),
615            "TagResource" => self.tag_resource(req, &body),
616            "UntagResource" => self.untag_resource(req, &body),
617            _ => Err(AwsServiceError::action_not_implemented("ram", action)),
618        }
619    }
620
621    // ---------------- resource shares ----------------
622
623    fn create_resource_share(
624        &self,
625        req: &AwsRequest,
626        body: &Value,
627    ) -> Result<AwsResponse, AwsServiceError> {
628        let name = req_str(body, "name")?;
629        let now = Utc::now();
630        let id = gen_id();
631        let arn = resource_share_arn(&req.region, &req.account_id, &id);
632        let tags = parse_tag_list(body, "tags");
633        let retain = body
634            .get("resourceShareConfiguration")
635            .and_then(|c| c.get("retainSharingOnAccountLeaveOrganization"))
636            .and_then(|v| v.as_bool());
637
638        let mut resource_associations = Vec::new();
639        for r in string_list(body, "resourceArns") {
640            resource_associations.push(AssociationRecord {
641                associated_entity: r,
642                association_type: "RESOURCE".into(),
643                status: "ASSOCIATED".into(),
644                status_message: None,
645                creation_time: now,
646                last_updated_time: now,
647                external: false,
648            });
649        }
650        let mut principal_associations = Vec::new();
651        let mut invitations = Vec::new();
652        for p in string_list(body, "principals") {
653            let external = is_external_principal(&p, &req.account_id);
654            principal_associations.push(AssociationRecord {
655                associated_entity: p.clone(),
656                association_type: "PRINCIPAL".into(),
657                status: "ASSOCIATED".into(),
658                status_message: None,
659                creation_time: now,
660                last_updated_time: now,
661                external,
662            });
663            // Sharing to an external account raises a pending invitation.
664            if external && p.len() == 12 && p.chars().all(|c| c.is_ascii_digit()) {
665                invitations.push(InvitationRecord {
666                    arn: invitation_arn(&req.region, &req.account_id, &gen_id()),
667                    resource_share_arn: arn.clone(),
668                    resource_share_name: name.clone(),
669                    sender_account_id: req.account_id.clone(),
670                    receiver_account_id: p.clone(),
671                    invitation_timestamp: now,
672                    status: "PENDING".into(),
673                });
674            }
675        }
676
677        let record = ResourceShareRecord {
678            arn: arn.clone(),
679            name,
680            owning_account_id: req.account_id.clone(),
681            allow_external_principals: bool_field(body, "allowExternalPrincipals").unwrap_or(true),
682            status: "ACTIVE".into(),
683            status_message: None,
684            creation_time: now,
685            last_updated_time: now,
686            feature_set: "STANDARD".into(),
687            tags,
688            retain_sharing: retain,
689            resource_associations,
690            principal_associations,
691            permission_arns: string_list(body, "permissionArns"),
692        };
693        let value = share_to_value(&record);
694
695        let mut accounts = self.state.write();
696        accounts
697            .get_or_create(&req.account_id)
698            .resource_shares
699            .insert(arn, record);
700        // Invitations are receiver-side objects: store each one in the receiver
701        // account's partition so that account's GetResourceShareInvitations /
702        // Accept / Reject can find it.
703        for inv in invitations {
704            let receiver = inv.receiver_account_id.clone();
705            accounts
706                .get_or_create(&receiver)
707                .invitations
708                .insert(inv.arn.clone(), inv);
709        }
710
711        Ok(ok(json!({
712            "resourceShare": value,
713            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
714        })))
715    }
716
717    fn update_resource_share(
718        &self,
719        req: &AwsRequest,
720        body: &Value,
721    ) -> Result<AwsResponse, AwsServiceError> {
722        let arn = req_str(body, "resourceShareArn")?;
723        let mut accounts = self.state.write();
724        let st = accounts.get_or_create(&req.account_id);
725        let share = st
726            .resource_shares
727            .get_mut(&arn)
728            .ok_or_else(|| unknown_resource(format!("ResourceShare {arn} could not be found.")))?;
729        if let Some(name) = str_field(body, "name") {
730            share.name = name;
731        }
732        if let Some(allow) = bool_field(body, "allowExternalPrincipals") {
733            share.allow_external_principals = allow;
734        }
735        share.last_updated_time = Utc::now();
736        let value = share_to_value(share);
737        Ok(ok(json!({
738            "resourceShare": value,
739            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
740        })))
741    }
742
743    fn delete_resource_share(
744        &self,
745        req: &AwsRequest,
746        _body: &Value,
747    ) -> Result<AwsResponse, AwsServiceError> {
748        let arn = query_str(req, "resourceShareArn")
749            .ok_or_else(|| invalid_param("resourceShareArn is required."))?;
750        let mut accounts = self.state.write();
751        let st = accounts.get_or_create(&req.account_id);
752        if st.resource_shares.remove(&arn).is_none() {
753            return Err(unknown_resource(format!(
754                "ResourceShare {arn} could not be found."
755            )));
756        }
757        st.tags.remove(&arn);
758        Ok(ok(json!({
759            "returnValue": true,
760            "clientToken": query_str(req, "clientToken").unwrap_or_else(gen_id),
761        })))
762    }
763
764    fn get_resource_shares(
765        &self,
766        req: &AwsRequest,
767        body: &Value,
768    ) -> Result<AwsResponse, AwsServiceError> {
769        let owner = req_str(body, "resourceOwner")?;
770        let arns_filter = string_list(body, "resourceShareArns");
771        let status_filter = str_field(body, "resourceShareStatus");
772        let name_filter = str_field(body, "name");
773        let accounts = self.state.read();
774        // SELF lists the account's own shares; OTHER_ACCOUNTS lists inbound
775        // shares (accepted invitations) whose owner is a different account.
776        let items: Vec<Value> = accounts
777            .get(&req.account_id)
778            .map(|st| {
779                st.resource_shares
780                    .values()
781                    .filter(|s| owner_matches(s, &owner, &req.account_id))
782                    .filter(|s| arns_filter.is_empty() || arns_filter.contains(&s.arn))
783                    .filter(|s| status_filter.as_deref().is_none_or(|st| st == s.status))
784                    .filter(|s| name_filter.as_deref().is_none_or(|n| n == s.name))
785                    .map(share_to_value)
786                    .collect()
787            })
788            .unwrap_or_default();
789        page_response(items, "resourceShares", body, &[])
790    }
791
792    fn associate_resource_share(
793        &self,
794        req: &AwsRequest,
795        body: &Value,
796        associate: bool,
797    ) -> Result<AwsResponse, AwsServiceError> {
798        let arn = req_str(body, "resourceShareArn")?;
799        let now = Utc::now();
800        let mut accounts = self.state.write();
801        let st = accounts.get_or_create(&req.account_id);
802        let share = st
803            .resource_shares
804            .get_mut(&arn)
805            .ok_or_else(|| unknown_resource(format!("ResourceShare {arn} could not be found.")))?;
806        let name = share.name.clone();
807        let mut affected: Vec<Value> = Vec::new();
808        let status = if associate {
809            "ASSOCIATED"
810        } else {
811            "DISASSOCIATED"
812        };
813
814        for r in string_list(body, "resourceArns") {
815            if associate {
816                share.resource_associations.push(AssociationRecord {
817                    associated_entity: r.clone(),
818                    association_type: "RESOURCE".into(),
819                    status: status.into(),
820                    status_message: None,
821                    creation_time: now,
822                    last_updated_time: now,
823                    external: false,
824                });
825            } else {
826                share
827                    .resource_associations
828                    .retain(|a| a.associated_entity != r);
829            }
830            affected.push(association_to_value(
831                &arn,
832                &name,
833                &AssociationRecord {
834                    associated_entity: r,
835                    association_type: "RESOURCE".into(),
836                    status: status.into(),
837                    status_message: None,
838                    creation_time: now,
839                    last_updated_time: now,
840                    external: false,
841                },
842            ));
843        }
844        for p in string_list(body, "principals") {
845            let external = is_external_principal(&p, &req.account_id);
846            if associate {
847                share.principal_associations.push(AssociationRecord {
848                    associated_entity: p.clone(),
849                    association_type: "PRINCIPAL".into(),
850                    status: status.into(),
851                    status_message: None,
852                    creation_time: now,
853                    last_updated_time: now,
854                    external,
855                });
856            } else {
857                share
858                    .principal_associations
859                    .retain(|a| a.associated_entity != p);
860            }
861            affected.push(association_to_value(
862                &arn,
863                &name,
864                &AssociationRecord {
865                    associated_entity: p,
866                    association_type: "PRINCIPAL".into(),
867                    status: status.into(),
868                    status_message: None,
869                    creation_time: now,
870                    last_updated_time: now,
871                    external,
872                },
873            ));
874        }
875        share.last_updated_time = now;
876        Ok(ok(json!({
877            "resourceShareAssociations": affected,
878            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
879        })))
880    }
881
882    fn associate_permission(
883        &self,
884        req: &AwsRequest,
885        body: &Value,
886        associate: bool,
887    ) -> Result<AwsResponse, AwsServiceError> {
888        let arn = req_str(body, "resourceShareArn")?;
889        let permission = req_str(body, "permissionArn")?;
890        let mut accounts = self.state.write();
891        let st = accounts.get_or_create(&req.account_id);
892        let share = st
893            .resource_shares
894            .get_mut(&arn)
895            .ok_or_else(|| unknown_resource(format!("ResourceShare {arn} could not be found.")))?;
896        if associate {
897            if !share.permission_arns.contains(&permission) {
898                share.permission_arns.push(permission);
899            }
900        } else {
901            share.permission_arns.retain(|p| p != &permission);
902        }
903        share.last_updated_time = Utc::now();
904        Ok(ok(json!({
905            "returnValue": true,
906            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
907        })))
908    }
909
910    fn get_share_associations(
911        &self,
912        _req: &AwsRequest,
913        body: &Value,
914    ) -> Result<AwsResponse, AwsServiceError> {
915        let assoc_type = req_str(body, "associationType")?;
916        let arns_filter = string_list(body, "resourceShareArns");
917        let status_filter = str_field(body, "associationStatus");
918        let accounts = self.state.read();
919        let mut items: Vec<Value> = Vec::new();
920        if let Some(st) = accounts.get(&_req.account_id) {
921            for share in st.resource_shares.values() {
922                if !arns_filter.is_empty() && !arns_filter.contains(&share.arn) {
923                    continue;
924                }
925                let src = if assoc_type == "PRINCIPAL" {
926                    &share.principal_associations
927                } else {
928                    &share.resource_associations
929                };
930                for a in src {
931                    if status_filter.as_deref().is_some_and(|s| s != a.status) {
932                        continue;
933                    }
934                    items.push(association_to_value(&share.arn, &share.name, a));
935                }
936            }
937        }
938        page_response(items, "resourceShareAssociations", body, &[])
939    }
940
941    fn get_invitations(
942        &self,
943        req: &AwsRequest,
944        body: &Value,
945    ) -> Result<AwsResponse, AwsServiceError> {
946        let inv_filter = string_list(body, "resourceShareInvitationArns");
947        let share_filter = string_list(body, "resourceShareArns");
948        let accounts = self.state.read();
949        let items: Vec<Value> = accounts
950            .get(&req.account_id)
951            .map(|st| {
952                st.invitations
953                    .values()
954                    .filter(|i| inv_filter.is_empty() || inv_filter.contains(&i.arn))
955                    .filter(|i| {
956                        share_filter.is_empty() || share_filter.contains(&i.resource_share_arn)
957                    })
958                    .map(invitation_to_value)
959                    .collect()
960            })
961            .unwrap_or_default();
962        page_response(items, "resourceShareInvitations", body, &[])
963    }
964
965    fn respond_invitation(
966        &self,
967        req: &AwsRequest,
968        body: &Value,
969        accept: bool,
970    ) -> Result<AwsResponse, AwsServiceError> {
971        let arn = req_str(body, "resourceShareInvitationArn")?;
972        let mut accounts = self.state.write();
973        // The invitation lives in the receiver (caller) account's partition.
974        let st = accounts.get_or_create(&req.account_id);
975        let inv = st.invitations.get_mut(&arn).ok_or_else(|| {
976            invitation_not_found(format!("ResourceShareInvitation {arn} could not be found."))
977        })?;
978        if inv.status != "PENDING" {
979            let code = if inv.status == "ACCEPTED" {
980                "ResourceShareInvitationAlreadyAcceptedException"
981            } else {
982                "ResourceShareInvitationAlreadyRejectedException"
983            };
984            return Err(aws_err(code, format!("Invitation {arn} is {}", inv.status)));
985        }
986        inv.status = if accept { "ACCEPTED" } else { "REJECTED" }.into();
987        let value = invitation_to_value(inv);
988        let sender = inv.sender_account_id.clone();
989        let share_arn = inv.resource_share_arn.clone();
990
991        if accept {
992            // Materialise an inbound view of the shared share in the receiver's
993            // partition so GetResourceShares(OTHER_ACCOUNTS) / ListResources /
994            // ListPrincipals surface it. The clone keeps owningAccountId set to
995            // the sender, which the `resourceOwner` filter uses to tell inbound
996            // shares apart from the caller's own.
997            if let Some(shared) = accounts
998                .get(&sender)
999                .and_then(|s| s.resource_shares.get(&share_arn))
1000                .cloned()
1001            {
1002                accounts
1003                    .get_or_create(&req.account_id)
1004                    .resource_shares
1005                    .insert(share_arn, shared);
1006            }
1007        }
1008        Ok(ok(json!({
1009            "resourceShareInvitation": value,
1010            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1011        })))
1012    }
1013
1014    fn list_pending_invitation_resources(
1015        &self,
1016        req: &AwsRequest,
1017        body: &Value,
1018    ) -> Result<AwsResponse, AwsServiceError> {
1019        let arn = req_str(body, "resourceShareInvitationArn")?;
1020        let accounts = self.state.read();
1021        let found = accounts
1022            .get(&req.account_id)
1023            .is_some_and(|st| st.invitations.contains_key(&arn));
1024        if !found {
1025            return Err(invitation_not_found(format!(
1026                "ResourceShareInvitation {arn} could not be found."
1027            )));
1028        }
1029        page_response(Vec::new(), "resources", body, &[])
1030    }
1031
1032    // ---------------- permissions ----------------
1033
1034    fn create_permission(
1035        &self,
1036        req: &AwsRequest,
1037        body: &Value,
1038    ) -> Result<AwsResponse, AwsServiceError> {
1039        let name = req_str(body, "name")?;
1040        let resource_type = req_str(body, "resourceType")?;
1041        let policy = req_str(body, "policyTemplate")?;
1042        let now = Utc::now();
1043        let arn = permission_arn(&req.region, &req.account_id, &name);
1044        let mut accounts = self.state.write();
1045        let st = accounts.get_or_create(&req.account_id);
1046        if st.permissions.contains_key(&arn) {
1047            return Err(aws_err(
1048                "PermissionAlreadyExistsException",
1049                format!("A permission named {name} already exists."),
1050            ));
1051        }
1052        let record = PermissionRecord {
1053            arn: arn.clone(),
1054            name,
1055            resource_type,
1056            status: "ATTACHABLE".into(),
1057            feature_set: "STANDARD".into(),
1058            creation_time: now,
1059            last_updated_time: now,
1060            default_version: 1,
1061            versions: vec![PermissionVersionRecord {
1062                version: 1,
1063                policy_template: policy,
1064                is_default: true,
1065                creation_time: now,
1066                last_updated_time: now,
1067            }],
1068            tags: parse_tag_list(body, "tags"),
1069        };
1070        let value = customer_summary(&record);
1071        st.permissions.insert(arn, record);
1072        Ok(ok(json!({
1073            "permission": value,
1074            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1075        })))
1076    }
1077
1078    fn create_permission_version(
1079        &self,
1080        req: &AwsRequest,
1081        body: &Value,
1082    ) -> Result<AwsResponse, AwsServiceError> {
1083        let arn = req_str(body, "permissionArn")?;
1084        let policy = req_str(body, "policyTemplate")?;
1085        let now = Utc::now();
1086        let mut accounts = self.state.write();
1087        let st = accounts.get_or_create(&req.account_id);
1088        let p = st
1089            .permissions
1090            .get_mut(&arn)
1091            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1092        let next = p.versions.iter().map(|v| v.version).max().unwrap_or(0) + 1;
1093        let rec = PermissionVersionRecord {
1094            version: next,
1095            policy_template: policy,
1096            is_default: false,
1097            creation_time: now,
1098            last_updated_time: now,
1099        };
1100        p.versions.push(rec.clone());
1101        p.last_updated_time = now;
1102        Ok(ok(json!({
1103            "permission": customer_detail(p, &rec),
1104            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1105        })))
1106    }
1107
1108    fn delete_permission(
1109        &self,
1110        req: &AwsRequest,
1111        _body: &Value,
1112    ) -> Result<AwsResponse, AwsServiceError> {
1113        let arn = query_str(req, "permissionArn")
1114            .ok_or_else(|| invalid_param("permissionArn is required."))?;
1115        let mut accounts = self.state.write();
1116        let st = accounts.get_or_create(&req.account_id);
1117        if st.permissions.remove(&arn).is_none() {
1118            return Err(unknown_resource(format!(
1119                "Permission {arn} could not be found."
1120            )));
1121        }
1122        Ok(ok(json!({
1123            "returnValue": true,
1124            "clientToken": query_str(req, "clientToken").unwrap_or_else(gen_id),
1125            "permissionStatus": "DELETED",
1126        })))
1127    }
1128
1129    fn delete_permission_version(
1130        &self,
1131        req: &AwsRequest,
1132        _body: &Value,
1133    ) -> Result<AwsResponse, AwsServiceError> {
1134        let arn = query_str(req, "permissionArn")
1135            .ok_or_else(|| invalid_param("permissionArn is required."))?;
1136        let version = query_str(req, "permissionVersion")
1137            .and_then(|s| s.parse::<u32>().ok())
1138            .ok_or_else(|| invalid_param("permissionVersion is required."))?;
1139        let mut accounts = self.state.write();
1140        let st = accounts.get_or_create(&req.account_id);
1141        let p = st
1142            .permissions
1143            .get_mut(&arn)
1144            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1145        if !p.versions.iter().any(|v| v.version == version) {
1146            return Err(invalid_param(format!(
1147                "Permission version {version} could not be found."
1148            )));
1149        }
1150        // AWS refuses to delete the default version (it would leave the
1151        // permission without a resolvable default); the caller must first move
1152        // the default with SetDefaultPermissionVersion.
1153        if version == p.default_version {
1154            return Err(aws_err(
1155                "OperationNotPermittedException",
1156                format!("The default version {version} of a permission cannot be deleted."),
1157            ));
1158        }
1159        p.versions.retain(|v| v.version != version);
1160        // A non-default version was removed, so at least the default remains.
1161        let status = "ATTACHABLE";
1162        Ok(ok(json!({
1163            "returnValue": true,
1164            "clientToken": query_str(req, "clientToken").unwrap_or_else(gen_id),
1165            "permissionStatus": status,
1166        })))
1167    }
1168
1169    fn get_permission(
1170        &self,
1171        req: &AwsRequest,
1172        body: &Value,
1173    ) -> Result<AwsResponse, AwsServiceError> {
1174        let arn = req_str(body, "permissionArn")?;
1175        let now = Utc::now();
1176        if let Some((name, rt)) = find_managed(&arn) {
1177            return Ok(ok(json!({ "permission": managed_detail(name, rt, now) })));
1178        }
1179        let accounts = self.state.read();
1180        let p = accounts
1181            .get(&req.account_id)
1182            .and_then(|st| st.permissions.get(&arn))
1183            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1184        let want = u32_field(body, "permissionVersion");
1185        let v = match want {
1186            Some(n) => p.versions.iter().find(|v| v.version == n),
1187            None => p.versions.iter().find(|v| v.version == p.default_version),
1188        }
1189        .ok_or_else(|| invalid_param("Permission version could not be found."))?;
1190        Ok(ok(json!({ "permission": customer_detail(p, v) })))
1191    }
1192
1193    fn list_permissions(
1194        &self,
1195        req: &AwsRequest,
1196        body: &Value,
1197    ) -> Result<AwsResponse, AwsServiceError> {
1198        let now = Utc::now();
1199        let type_filter = str_field(body, "resourceType");
1200        let perm_type = str_field(body, "permissionType").unwrap_or_else(|| "ALL".into());
1201        let mut items: Vec<Value> = Vec::new();
1202        if perm_type == "ALL" || perm_type == "AWS_MANAGED" {
1203            for (name, rt) in MANAGED_PERMISSIONS {
1204                if type_filter.as_deref().is_none_or(|t| t == *rt) {
1205                    items.push(managed_summary(name, rt, now));
1206                }
1207            }
1208        }
1209        if perm_type == "ALL" || perm_type == "CUSTOMER_MANAGED" {
1210            if let Some(st) = self.state.read().get(&req.account_id) {
1211                for p in st.permissions.values() {
1212                    if type_filter.as_deref().is_none_or(|t| t == p.resource_type) {
1213                        items.push(customer_summary(p));
1214                    }
1215                }
1216            }
1217        }
1218        page_response(items, "permissions", body, &[])
1219    }
1220
1221    fn list_permission_versions(
1222        &self,
1223        req: &AwsRequest,
1224        body: &Value,
1225    ) -> Result<AwsResponse, AwsServiceError> {
1226        let arn = req_str(body, "permissionArn")?;
1227        let accounts = self.state.read();
1228        let p = accounts
1229            .get(&req.account_id)
1230            .and_then(|st| st.permissions.get(&arn))
1231            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1232        let items: Vec<Value> = p
1233            .versions
1234            .iter()
1235            .map(|v| {
1236                json!({
1237                    "arn": p.arn,
1238                    "version": v.version.to_string(),
1239                    "defaultVersion": v.is_default,
1240                    "name": p.name,
1241                    "resourceType": p.resource_type,
1242                    "status": p.status,
1243                    "creationTime": ts(v.creation_time),
1244                    "lastUpdatedTime": ts(v.last_updated_time),
1245                    "isResourceTypeDefault": false,
1246                    "permissionType": "CUSTOMER_MANAGED",
1247                    "featureSet": p.feature_set,
1248                    "tags": tag_list_value(&p.tags),
1249                })
1250            })
1251            .collect();
1252        page_response(items, "permissions", body, &[])
1253    }
1254
1255    fn list_resource_share_permissions(
1256        &self,
1257        req: &AwsRequest,
1258        body: &Value,
1259    ) -> Result<AwsResponse, AwsServiceError> {
1260        let arn = req_str(body, "resourceShareArn")?;
1261        let now = Utc::now();
1262        let accounts = self.state.read();
1263        let st = accounts
1264            .get(&req.account_id)
1265            .filter(|st| st.resource_shares.contains_key(&arn))
1266            .ok_or_else(|| unknown_resource(format!("ResourceShare {arn} could not be found.")))?;
1267        let share = &st.resource_shares[&arn];
1268        let mut items: Vec<Value> = Vec::new();
1269        for perm_arn in &share.permission_arns {
1270            if let Some((name, rt)) = find_managed(perm_arn) {
1271                items.push(managed_summary(name, rt, now));
1272            } else if let Some(p) = st.permissions.get(perm_arn) {
1273                items.push(customer_summary(p));
1274            }
1275        }
1276        page_response(items, "permissions", body, &[])
1277    }
1278
1279    fn set_default_permission_version(
1280        &self,
1281        req: &AwsRequest,
1282        body: &Value,
1283    ) -> Result<AwsResponse, AwsServiceError> {
1284        let arn = req_str(body, "permissionArn")?;
1285        let version = u32_field(body, "permissionVersion")
1286            .ok_or_else(|| invalid_param("permissionVersion is required."))?;
1287        let mut accounts = self.state.write();
1288        let st = accounts.get_or_create(&req.account_id);
1289        let p = st
1290            .permissions
1291            .get_mut(&arn)
1292            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1293        if !p.versions.iter().any(|v| v.version == version) {
1294            return Err(invalid_param(format!(
1295                "Permission version {version} could not be found."
1296            )));
1297        }
1298        p.default_version = version;
1299        for v in &mut p.versions {
1300            v.is_default = v.version == version;
1301        }
1302        p.last_updated_time = Utc::now();
1303        Ok(ok(json!({
1304            "returnValue": true,
1305            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1306        })))
1307    }
1308
1309    fn promote_permission(
1310        &self,
1311        req: &AwsRequest,
1312        body: &Value,
1313    ) -> Result<AwsResponse, AwsServiceError> {
1314        let arn = req_str(body, "permissionArn")?;
1315        let _name = req_str(body, "name")?;
1316        let mut accounts = self.state.write();
1317        let st = accounts.get_or_create(&req.account_id);
1318        let p = st
1319            .permissions
1320            .get_mut(&arn)
1321            .ok_or_else(|| unknown_resource(format!("Permission {arn} could not be found.")))?;
1322        p.feature_set = "STANDARD".into();
1323        p.last_updated_time = Utc::now();
1324        Ok(ok(json!({
1325            "permission": customer_summary(p),
1326            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1327        })))
1328    }
1329
1330    fn promote_resource_share(
1331        &self,
1332        req: &AwsRequest,
1333        body: &Value,
1334    ) -> Result<AwsResponse, AwsServiceError> {
1335        // `resourceShareArn` is bound to `@httpQuery`, so real SDK / Terraform
1336        // clients send it in the query string (empty body). Accept the body form
1337        // too for permissive round-tripping.
1338        let arn = query_str(req, "resourceShareArn")
1339            .or_else(|| str_field(body, "resourceShareArn"))
1340            .ok_or_else(|| invalid_param("resourceShareArn is required."))?;
1341        let mut accounts = self.state.write();
1342        let st = accounts.get_or_create(&req.account_id);
1343        let share = st
1344            .resource_shares
1345            .get_mut(&arn)
1346            .ok_or_else(|| unknown_resource(format!("ResourceShare {arn} could not be found.")))?;
1347        share.feature_set = "STANDARD".into();
1348        share.last_updated_time = Utc::now();
1349        Ok(ok(json!({ "returnValue": true })))
1350    }
1351
1352    fn replace_permission_associations(
1353        &self,
1354        req: &AwsRequest,
1355        body: &Value,
1356    ) -> Result<AwsResponse, AwsServiceError> {
1357        let from = req_str(body, "fromPermissionArn")?;
1358        let to = req_str(body, "toPermissionArn")?;
1359        let now = Utc::now();
1360        // The source permission must exist (managed or customer-managed).
1361        let known = find_managed(&from).is_some()
1362            || self
1363                .state
1364                .read()
1365                .get(&req.account_id)
1366                .is_some_and(|st| st.permissions.contains_key(&from));
1367        if !known {
1368            return Err(unknown_resource(format!(
1369                "Permission {from} could not be found."
1370            )));
1371        }
1372        let from_version = u32_field(body, "fromPermissionVersion").unwrap_or(1);
1373        let id = gen_id();
1374        let work = json!({
1375            "id": id,
1376            "fromPermissionArn": from,
1377            "fromPermissionVersion": from_version.to_string(),
1378            "toPermissionArn": to,
1379            "toPermissionVersion": "1",
1380            "status": "IN_PROGRESS",
1381            "creationTime": ts(now),
1382            "lastUpdatedTime": ts(now),
1383        });
1384        let mut accounts = self.state.write();
1385        let st = accounts.get_or_create(&req.account_id);
1386        st.replace_works.insert(id, work.clone());
1387        Ok(ok(json!({
1388            "replacePermissionAssociationsWork": work,
1389            "clientToken": str_field(body, "clientToken").unwrap_or_else(gen_id),
1390        })))
1391    }
1392
1393    fn list_permission_associations(
1394        &self,
1395        req: &AwsRequest,
1396        body: &Value,
1397    ) -> Result<AwsResponse, AwsServiceError> {
1398        let perm_filter = str_field(body, "permissionArn");
1399        let version_filter = u32_field(body, "permissionVersion");
1400        let default_filter = bool_field(body, "defaultVersion");
1401        let status_filter = str_field(body, "associationStatus");
1402        let type_filter = str_field(body, "resourceType");
1403        let mut items: Vec<Value> = Vec::new();
1404        if let Some(st) = self.state.read().get(&req.account_id) {
1405            for share in st.resource_shares.values() {
1406                for perm_arn in &share.permission_arns {
1407                    if perm_filter.as_deref().is_some_and(|p| p != perm_arn) {
1408                        continue;
1409                    }
1410                    // Resolve the permission's type / version / feature set from
1411                    // the managed catalogue or the account's customer permissions.
1412                    let (rtype, version, feature) = if let Some((_, rt)) = find_managed(perm_arn) {
1413                        (rt.to_string(), 1u32, "STANDARD".to_string())
1414                    } else if let Some(p) = st.permissions.get(perm_arn) {
1415                        (
1416                            p.resource_type.clone(),
1417                            p.default_version,
1418                            p.feature_set.clone(),
1419                        )
1420                    } else {
1421                        ("unknown".to_string(), 1, "STANDARD".to_string())
1422                    };
1423                    if type_filter.as_deref().is_some_and(|t| t != rtype) {
1424                        continue;
1425                    }
1426                    if version_filter.is_some_and(|v| v != version) {
1427                        continue;
1428                    }
1429                    // Every association here uses the default version.
1430                    if default_filter == Some(false) {
1431                        continue;
1432                    }
1433                    if status_filter.as_deref().is_some_and(|s| s != "ASSOCIATED") {
1434                        continue;
1435                    }
1436                    items.push(json!({
1437                        "arn": perm_arn,
1438                        "permissionVersion": version.to_string(),
1439                        "defaultVersion": true,
1440                        "resourceType": rtype,
1441                        "status": "ASSOCIATED",
1442                        "featureSet": feature,
1443                        "lastUpdatedTime": ts(share.last_updated_time),
1444                        "resourceShareArn": share.arn,
1445                    }));
1446                }
1447            }
1448        }
1449        page_response(items, "permissions", body, &[])
1450    }
1451
1452    fn list_replace_work(
1453        &self,
1454        req: &AwsRequest,
1455        body: &Value,
1456    ) -> Result<AwsResponse, AwsServiceError> {
1457        let id_filter = string_list(body, "workIds");
1458        let status_filter = str_field(body, "status");
1459        let accounts = self.state.read();
1460        let items: Vec<Value> = accounts
1461            .get(&req.account_id)
1462            .map(|st| {
1463                st.replace_works
1464                    .iter()
1465                    .filter(|(id, _)| id_filter.is_empty() || id_filter.contains(id))
1466                    .filter(|(_, w)| {
1467                        status_filter
1468                            .as_deref()
1469                            .is_none_or(|s| w.get("status").and_then(|v| v.as_str()) == Some(s))
1470                    })
1471                    .map(|(_, w)| w.clone())
1472                    .collect()
1473            })
1474            .unwrap_or_default();
1475        page_response(items, "replacePermissionAssociationsWorks", body, &[])
1476    }
1477
1478    // ---------------- resources / principals / types ----------------
1479
1480    fn list_principals(
1481        &self,
1482        req: &AwsRequest,
1483        body: &Value,
1484    ) -> Result<AwsResponse, AwsServiceError> {
1485        let owner = req_str(body, "resourceOwner")?;
1486        let principal_filter = string_list(body, "principals");
1487        let arns_filter = string_list(body, "resourceShareArns");
1488        let mut items: Vec<Value> = Vec::new();
1489        if let Some(st) = self.state.read().get(&req.account_id) {
1490            for share in st.resource_shares.values() {
1491                if !owner_matches(share, &owner, &req.account_id) {
1492                    continue;
1493                }
1494                if !arns_filter.is_empty() && !arns_filter.contains(&share.arn) {
1495                    continue;
1496                }
1497                for a in &share.principal_associations {
1498                    if !principal_filter.is_empty()
1499                        && !principal_filter.contains(&a.associated_entity)
1500                    {
1501                        continue;
1502                    }
1503                    items.push(json!({
1504                        "id": a.associated_entity,
1505                        "resourceShareArn": share.arn,
1506                        "creationTime": ts(a.creation_time),
1507                        "lastUpdatedTime": ts(a.last_updated_time),
1508                        "external": a.external,
1509                    }));
1510                }
1511            }
1512        }
1513        page_response(items, "principals", body, &[])
1514    }
1515
1516    fn list_resources(
1517        &self,
1518        req: &AwsRequest,
1519        body: &Value,
1520    ) -> Result<AwsResponse, AwsServiceError> {
1521        let owner = req_str(body, "resourceOwner")?;
1522        let arns_filter = string_list(body, "resourceArns");
1523        let type_filter = str_field(body, "resourceType");
1524        let mut items: Vec<Value> = Vec::new();
1525        if let Some(st) = self.state.read().get(&req.account_id) {
1526            for share in st.resource_shares.values() {
1527                if !owner_matches(share, &owner, &req.account_id) {
1528                    continue;
1529                }
1530                for a in &share.resource_associations {
1531                    if !arns_filter.is_empty() && !arns_filter.contains(&a.associated_entity) {
1532                        continue;
1533                    }
1534                    let rtype = ram_resource_type(&a.associated_entity);
1535                    if type_filter.as_deref().is_some_and(|t| t != rtype) {
1536                        continue;
1537                    }
1538                    items.push(json!({
1539                        "arn": a.associated_entity,
1540                        "type": rtype,
1541                        "resourceShareArn": share.arn,
1542                        "status": "AVAILABLE",
1543                        "creationTime": ts(a.creation_time),
1544                        "lastUpdatedTime": ts(a.last_updated_time),
1545                        "resourceRegionScope": "REGIONAL",
1546                    }));
1547                }
1548            }
1549        }
1550        page_response(items, "resources", body, &[])
1551    }
1552
1553    fn list_resource_types(
1554        &self,
1555        _req: &AwsRequest,
1556        body: &Value,
1557    ) -> Result<AwsResponse, AwsServiceError> {
1558        let scope_filter = str_field(body, "resourceRegionScope");
1559        let items: Vec<Value> = RESOURCE_TYPES
1560            .iter()
1561            .filter(|(_, _, scope)| match scope_filter.as_deref() {
1562                None | Some("ALL") => true,
1563                Some(s) => s == *scope,
1564            })
1565            .map(|(rt, svc, scope)| {
1566                json!({
1567                    "resourceType": rt,
1568                    "serviceName": svc,
1569                    "resourceRegionScope": scope,
1570                })
1571            })
1572            .collect();
1573        page_response(items, "resourceTypes", body, &[])
1574    }
1575
1576    fn get_resource_policies(
1577        &self,
1578        _req: &AwsRequest,
1579        body: &Value,
1580    ) -> Result<AwsResponse, AwsServiceError> {
1581        // No shared-resource policies are materialised for synthetic ARNs.
1582        page_response(Vec::new(), "policies", body, &[])
1583    }
1584
1585    // ---------------- tags ----------------
1586
1587    fn tag_resource(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1588        let tags = parse_tag_list(body, "tags");
1589        let target = str_field(body, "resourceShareArn").or_else(|| str_field(body, "resourceArn"));
1590        let target = target.ok_or_else(|| {
1591            invalid_param("Either resourceShareArn or resourceArn must be specified.")
1592        })?;
1593        let mut accounts = self.state.write();
1594        let st = accounts.get_or_create(&req.account_id);
1595        if let Some(share) = st.resource_shares.get_mut(&target) {
1596            share.tags.extend(tags);
1597        } else if let Some(perm) = st.permissions.get_mut(&target) {
1598            perm.tags.extend(tags);
1599        } else {
1600            return Err(unknown_resource(format!(
1601                "Resource {target} could not be found."
1602            )));
1603        }
1604        Ok(ok(json!({})))
1605    }
1606
1607    fn untag_resource(
1608        &self,
1609        req: &AwsRequest,
1610        body: &Value,
1611    ) -> Result<AwsResponse, AwsServiceError> {
1612        let keys = string_list(body, "tagKeys");
1613        let target = str_field(body, "resourceShareArn").or_else(|| str_field(body, "resourceArn"));
1614        let target = target.ok_or_else(|| {
1615            invalid_param("Either resourceShareArn or resourceArn must be specified.")
1616        })?;
1617        let mut accounts = self.state.write();
1618        let st = accounts.get_or_create(&req.account_id);
1619        if let Some(share) = st.resource_shares.get_mut(&target) {
1620            for k in &keys {
1621                share.tags.remove(k);
1622            }
1623        } else if let Some(perm) = st.permissions.get_mut(&target) {
1624            for k in &keys {
1625                perm.tags.remove(k);
1626            }
1627        } else {
1628            return Err(unknown_resource(format!(
1629                "Resource {target} could not be found."
1630            )));
1631        }
1632        Ok(ok(json!({})))
1633    }
1634}