DocsAWS 101BlogServices

Multi-tenancy

MiniStack is a single process, but each request is tagged with an account ID and region. State is partitioned per account — giving you LocalStack-Pro-style namespace isolation without spinning up multiple emulators.

Account inference

MiniStack picks an account ID from (in order):

  • Access key ID — if the request carries a SigV4 Authorization header and the access key is a 12-digit string, that's the account ID. Lets you drive separate accounts with e.g. AWS_ACCESS_KEY_ID=111111111111 vs 222222222222.
  • Custom tokens with an embedded account — some tooling passes session tokens that include the account.
  • Default000000000000 (the historical LocalStack default). Some code paths still use 123456789012; both normalize to the same pool under AccountScopedDict.
No signature check. MiniStack does not validate SigV4 signatures. The access key is read only to derive the account — any string works.

Regions

The region comes from the SigV4 credential scope (.../us-east-1/s3/aws4_request). Unsigned callers fall back to MINISTACK_REGION (default us-east-1).

Regional ARNs, endpoint URLs, and STS GetCallerIdentity responses all honor the per-request region. Two callers signing with different regions from the same process get region-correct responses — no single global region setting is used at request time.

State partitioning is per-account; most services share state across regions within an account. If you exercise two regions from the same account, use unique resource names to avoid collisions. This matches LocalStack's default and is the pragmatic choice for most tests.

AccountScopedDict

AccountScopedDict (in ministack/core/) is a dict wrapper that transparently partitions keys by the current request's account ID. A service module writes self._tables[table_name] = … as if it's a flat dict; the wrapper stores it under (account_id, table_name). When account 111…111 reads _tables, it sees only its own entries.

# Inside a service module
self._queues = AccountScopedDict()
self._queues[name] = queue_data   # scoped to request.account_id automatically

The request's account is set via a context-local (set_request_account_id()) at the top of the router, so handlers can stay oblivious.

Leaky services

Not every service is wrapped. A handful still use plain dicts — meaning two accounts can see each other's data. These are the known leaks as of 1.3.14:

ServiceNature of the leak
CloudWatch metricsPlain-dict metric store; alarms and metric data bleed across accounts.
EventBridge (parts)Some internal maps (event bus, rule state) are not fully scoped.
SESSent-message store is per-account-filterable at /_ministack/ses/messages, but underlying maps are shared.
ElastiCacheContainer tracking is by logical cluster name — two accounts creating the same name collide.

See Known limitations for the full cross-service gap list.

Using multiple accounts

Set distinct access keys for each logical account. No other configuration is needed.

export AWS_ACCESS_KEY_ID=111111111111
aws --endpoint-url=http://localhost:4566 s3 mb s3://account-one-bucket

export AWS_ACCESS_KEY_ID=222222222222
aws --endpoint-url=http://localhost:4566 s3 mb s3://account-two-bucket
aws --endpoint-url=http://localhost:4566 s3 ls   # only sees account-two-bucket

In Boto3/SDKs, construct one client per account with its own credential pair. The secret key is ignored but must be set — test is fine.

Tests that assert isolation: you can validate two accounts cannot see each other's resources by round-tripping a Create → List from the other account. Fails for the leaky services listed above — file an issue if the leak blocks you.