awsim-core 0.5.0

Core framework for AWSim — gateway, routing, protocol layer, state management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use awsim_iam_policy::{
    AuthzRequest, ContextValue, Decision, EvalContext, PolicyDocument, evaluate,
};

use crate::error::AwsError;
use crate::router::RequestContext;

#[derive(Clone)]
pub struct ResolvedPrincipal {
    pub arn: String,
    pub account: String,
    pub identity_policies: Vec<PolicyDocument>,
    pub permissions_boundary: Option<PolicyDocument>,
    pub is_root: bool,
    /// Principal tags, surfaced into `aws:PrincipalTag/<key>` for IAM
    /// condition evaluation. Empty for root and for federated principals
    /// without a backing IAM record.
    pub tags: HashMap<String, String>,
    /// Session policy captured at AssumeRole time. Populated by the
    /// STS-aware principal lookup when the resolved credential is an
    /// ASIA token; `None` for long-lived IAM-user keys. Real AWS
    /// narrows assumed-role permissions to the intersection of the
    /// role's identity policies and this document, so the AuthzEngine
    /// surfaces it into `EvalContext::session_policy`.
    pub session_policy: Option<PolicyDocument>,
}

pub trait PrincipalLookup: Send + Sync {
    fn resolve_access_key(&self, access_key: &str) -> Option<ResolvedPrincipal>;

    /// Look up a principal by its ARN. Used by chaining wrappers (the
    /// STS-aware lookup in particular) that hold a role ARN and need
    /// to materialise the role's identity policies. Default
    /// implementation returns `None` so existing impls don't have to
    /// add a stub.
    fn resolve_arn(&self, _arn: &str) -> Option<ResolvedPrincipal> {
        None
    }

    /// Look up the plaintext secret access key for a given access key
    /// ID. Used by the SigV4 verifier on the request path when
    /// `AWSIM_VERIFY_SIGV4` is enabled so the gateway can recompute
    /// the signature and reject impostors. Default returns `None`,
    /// which the verifier treats as "unknown principal" and rejects
    /// with `InvalidClientTokenId`.
    fn resolve_secret(&self, _access_key: &str) -> Option<String> {
        None
    }

    /// Record that a successful resolution just happened. AWS exposes
    /// this through `GetAccessKeyLastUsed`; the gateway hooks it after
    /// every authenticated request so callers see the timestamp slide
    /// forward without an explicit IAM API call. The default is a
    /// no-op so existing implementations (and tests) don't need to
    /// supply one.
    fn record_access_key_used(&self, _access_key: &str, _service: &str, _region: &str) {}
}

pub trait ResourcePolicyLookup: Send + Sync {
    fn lookup(&self, resource_arn: &str) -> Option<PolicyDocument>;
}

/// Service-specific authorization side-channel. Used by KMS grants today —
/// a grant is an out-of-band Allow that lets a principal perform listed
/// operations on a resource even if the identity policy and key policy
/// would otherwise deny. Returns `true` when at least one grant matches the
/// principal + action + resource.
pub trait GrantLookup: Send + Sync {
    fn allows(&self, principal_arn: &str, action: &str, resource_arn: &str) -> bool;
}

pub trait ScpLookup: Send + Sync {
    fn lookup(&self, principal_arn: &str) -> Vec<PolicyDocument>;
}

/// Resolve a KMS key reference (key id, key ARN, alias, or alias ARN)
/// into the canonical key id. Returns `None` when no such key/alias
/// exists in the given (account, region). Used by service crates that
/// accept a KMS key reference (SNS topics, SQS queues, log groups,
/// etc.) and need to validate the reference against awsim-kms without
/// taking a direct dependency on the kms crate.
pub trait KmsKeyLookup: Send + Sync {
    fn resolve_key(&self, key_ref: &str, account: &str, region: &str) -> Option<String>;
}

/// Resolve a SecretsManager secret reference (name or ARN). Returns
/// `true` when a matching secret exists in the given (account,
/// region). Used by service crates (ECS repositoryCredentials, ECS
/// container secrets[], EventBridge connection auth) that need to
/// validate a secret reference without depending on awsim-secretsmanager.
pub trait SecretLookup: Send + Sync {
    fn secret_exists(&self, secret_ref: &str, account: &str, region: &str) -> bool;
}

/// Resolve an SSM Parameter Store reference (parameter name or ARN).
/// Returns `true` when the parameter exists in the given (account,
/// region). Used by service crates that consume SSM parameter
/// references (ECS container `secrets[].valueFrom`, Lambda layer
/// configuration, etc.) without depending on awsim-ssm.
pub trait ParameterLookup: Send + Sync {
    fn parameter_exists(&self, parameter_ref: &str, account: &str, region: &str) -> bool;
}

/// Cross-service hook that lets ECS register a service as a Cloud Map
/// instance when CreateService specifies `serviceRegistries[]`. Two
/// distinct return signals: `true` when the registry exists and the
/// instance was recorded, `false` when the registry ARN doesn't
/// resolve so the caller can surface a clear error.
pub trait CloudMapRegistrar: Send + Sync {
    fn register_instance(
        &self,
        registry_arn: &str,
        instance_id: &str,
        attributes: &std::collections::HashMap<String, String>,
        account: &str,
        region: &str,
    ) -> bool;
    fn deregister_instance(
        &self,
        registry_arn: &str,
        instance_id: &str,
        account: &str,
        region: &str,
    );
}

/// Cross-service hook that lets services synchronously invoke a Lambda
/// function. Today Secrets Manager uses this to drive the four-step
/// rotation state machine (`createSecret` -> `setSecret` ->
/// `testSecret` -> `finishSecret`) against the customer's rotation
/// Lambda; other services with similar patterns (S3 Object Lambda,
/// Cognito custom-auth triggers) can adopt the same trait without
/// taking a direct dependency on awsim-lambda.
///
/// Returns the Lambda's response payload as a JSON value on success,
/// or an `AwsError` with code `ResourceNotFoundException` when the
/// function ARN doesn't resolve / `LambdaInvocationError` when the
/// runtime surfaced a `FunctionError`. The implementation is allowed
/// to block — Secrets Manager rotation already runs on the
/// `WorkerPool` so this is invoked off the request thread.
pub trait LambdaInvoker: Send + Sync {
    fn invoke(
        &self,
        function_name: &str,
        payload: &serde_json::Value,
        account: &str,
        region: &str,
    ) -> Result<serde_json::Value, AwsError>;
}

pub struct NoopPrincipalLookup;

impl PrincipalLookup for NoopPrincipalLookup {
    fn resolve_access_key(&self, _access_key: &str) -> Option<ResolvedPrincipal> {
        None
    }
}

pub struct AuthzEngine {
    pub principal_lookup: Arc<dyn PrincipalLookup>,
    pub resource_policy_lookups: HashMap<String, Arc<dyn ResourcePolicyLookup>>,
    pub grant_lookups: HashMap<String, Arc<dyn GrantLookup>>,
    pub scp_lookup: Option<Arc<dyn ScpLookup>>,
    /// Atomic so the runtime config can flip enforcement on/off
    /// without rebuilding the engine. Reads on the request path are
    /// `Relaxed` since enforcement-toggle ordering vs in-flight
    /// requests doesn't have correctness implications.
    enforced: AtomicBool,
    /// Access key that bypasses IAM enforcement and is treated as
    /// root-equivalent. Models the AWS account root credential —
    /// IAM only governs IAM users/roles, not the account itself.
    /// `None` means no bypass key is configured. The admin key is
    /// also not subject to `principal_lookup`, so it works even
    /// before any IAM users exist (bootstrap path).
    pub admin_access_key: Option<String>,
}

impl AuthzEngine {
    pub fn new(enabled: bool) -> Self {
        Self {
            principal_lookup: Arc::new(NoopPrincipalLookup),
            resource_policy_lookups: HashMap::new(),
            grant_lookups: HashMap::new(),
            scp_lookup: None,
            enforced: AtomicBool::new(enabled),
            admin_access_key: None,
        }
    }

    pub fn from_env() -> Self {
        let enabled = std::env::var("AWSIM_IAM_ENFORCE").ok().as_deref() == Some("true");
        let mut engine = Self::new(enabled);
        engine.admin_access_key = std::env::var("AWSIM_ADMIN_ACCESS_KEY")
            .ok()
            .filter(|s| !s.is_empty());
        engine
    }

    /// Enable or disable IAM enforcement. Hot-reload-safe: in-flight
    /// requests already past the `enabled` check see the previous
    /// value, which is fine — we don't make any policy decisions
    /// that depend on this being a stable view across an entire
    /// request.
    pub fn set_enabled(&self, enabled: bool) {
        self.enforced.store(enabled, Ordering::Relaxed);
    }

    pub fn enabled(&self) -> bool {
        self.enforced.load(Ordering::Relaxed)
    }

    /// Returns true when `key` matches the configured admin access
    /// key. Constant-time comparison isn't needed: the simulator
    /// doesn't verify signatures, so the key is already trivially
    /// observable to anyone on the loopback interface.
    fn is_admin_key(&self, key: &str) -> bool {
        self.admin_access_key.as_deref() == Some(key)
    }

    /// Public mirror of [`Self::is_admin_key`] for the gateway's
    /// signed-request gate, which needs to let the admin key
    /// through even when no IAM user maps to it (bootstrap path).
    pub fn is_admin_access_key(&self, key: &str) -> bool {
        self.is_admin_key(key)
    }

    pub fn check(
        &self,
        ctx: &RequestContext,
        action: &str,
        resource: &str,
    ) -> Result<(), AwsError> {
        if !self.enabled() {
            return Ok(());
        }

        let access_key = match ctx.access_key.as_deref() {
            Some(k) if !k.is_empty() => k,
            _ => {
                return Err(AwsError::access_denied_for(action, "anonymous", resource));
            }
        };

        // Admin key short-circuit. Mirrors how AWS root credentials sit
        // outside IAM: the management UI and bootstrap flows use this
        // key so they keep working once enforcement is on, even before
        // any IAM users exist.
        if self.is_admin_key(access_key) {
            return Ok(());
        }

        let principal = match self.principal_lookup.resolve_access_key(access_key) {
            Some(p) => p,
            None => {
                return Err(AwsError::access_denied_for(
                    action,
                    &format!("AccessKey:{access_key}"),
                    resource,
                ));
            }
        };

        if principal.is_root {
            return Ok(());
        }

        let resource_policy = self
            .resource_policy_lookups
            .get(&ctx.service)
            .and_then(|lookup| lookup.lookup(resource));

        let context = build_request_context(ctx, &principal);

        let req = AuthzRequest {
            principal_arn: &principal.arn,
            principal_account: &principal.account,
            action,
            resource_arn: resource,
            context: &context,
        };

        let scps: Vec<PolicyDocument> = self
            .scp_lookup
            .as_ref()
            .map(|l| l.lookup(&principal.arn))
            .unwrap_or_default();

        let eval_ctx = EvalContext {
            identity_policies: &principal.identity_policies,
            permissions_boundary: principal.permissions_boundary.as_ref(),
            resource_policy: resource_policy.as_ref(),
            scps: &scps,
            session_policy: principal.session_policy.as_ref(),
        };

        match evaluate(&req, &eval_ctx) {
            Decision::Allow => Ok(()),
            // Implicit deny is the natural outcome when neither the identity
            // policy nor the resource policy explicitly allows. KMS grants
            // are an out-of-band Allow path — give them a chance before we
            // actually fail the request. Explicit deny still wins absolutely.
            Decision::ImplicitDeny => {
                if let Some(lookup) = self.grant_lookups.get(&ctx.service)
                    && lookup.allows(&principal.arn, action, resource)
                {
                    return Ok(());
                }
                Err(AwsError::access_denied_for(
                    action,
                    &principal.arn,
                    resource,
                ))
            }
            Decision::ExplicitDeny => Err(AwsError::access_denied_for(
                action,
                &principal.arn,
                resource,
            )),
        }
    }
}

impl Default for AuthzEngine {
    fn default() -> Self {
        Self::new(false)
    }
}

impl AuthzEngine {
    /// Authorize the caller to hand `role_arn` to `target_service`.
    ///
    /// Resource-creating operations that bind a role to another
    /// service (Lambda `CreateFunction`, ECS `RunTask`,
    /// CodePipeline `CreatePipeline`, Bedrock model invocation
    /// roles, etc.) must verify the caller holds `iam:PassRole` on
    /// the target role. Mirrors AWS's pre-flight check; returns
    /// `AccessDeniedException` if enforcement is on and the policy
    /// denies. No-op when enforcement is off.
    ///
    /// `target_service` is the AWS service principal that will
    /// assume the role (e.g. `"lambda.amazonaws.com"`,
    /// `"ecs-tasks.amazonaws.com"`). It's threaded into the
    /// condition context as `iam:PassedToService` so policies that
    /// scope `PassRole` by service work correctly.
    pub fn check_pass_role(
        &self,
        ctx: &RequestContext,
        role_arn: &str,
        target_service: &str,
    ) -> Result<(), AwsError> {
        // We do not currently feed `iam:PassedToService` into the
        // condition context (the engine accepts the variable but no
        // call site sets it yet). The standard PassRole check still
        // runs through the normal evaluator path so identity
        // policies and SCPs apply.
        let _ = target_service;
        self.check(ctx, "iam:PassRole", role_arn)
    }
}

/// Build the IAM condition-context map for one request. Populates the
/// AWS-standard variables that the policy evaluator consumes:
///
/// * `aws:CurrentTime` (Date)
/// * `aws:EpochTime` (Number) — same instant as seconds since 1970
/// * `aws:SourceIp` (Ip), when the request carried a recoverable client IP
/// * `aws:SecureTransport` (Bool)
/// * `aws:PrincipalArn` / `aws:PrincipalAccount` — already known to the
///   evaluator's variable resolver but mirrored here so condition lookups
///   that reference them as keys (rare but legal) also see them.
/// * `aws:PrincipalTag/<key>` (String) for every tag on the resolved
///   principal.
fn build_request_context(
    ctx: &RequestContext,
    principal: &ResolvedPrincipal,
) -> HashMap<String, ContextValue> {
    let mut context = HashMap::new();
    let now = chrono::Utc::now();
    context.insert("aws:CurrentTime".to_string(), ContextValue::Date(now));
    context.insert(
        "aws:EpochTime".to_string(),
        ContextValue::Number(now.timestamp() as f64),
    );
    context.insert(
        "aws:SecureTransport".to_string(),
        ContextValue::Bool(ctx.is_secure),
    );
    if let Some(ref ip) = ctx.source_ip {
        context.insert("aws:SourceIp".to_string(), ContextValue::Ip(ip.clone()));
    }
    context.insert(
        "aws:PrincipalArn".to_string(),
        ContextValue::String(principal.arn.clone()),
    );
    context.insert(
        "aws:PrincipalAccount".to_string(),
        ContextValue::String(principal.account.clone()),
    );
    for (k, v) in &principal.tags {
        context.insert(
            format!("aws:PrincipalTag/{k}"),
            ContextValue::String(v.clone()),
        );
    }
    context
}