Skip to content

docs(examples): design negative caching for multi-tenant Hierarchical Keyrings - #3

Draft
texastony wants to merge 10 commits into
examples/credentialsfrom
examples/negative-caching
Draft

texastony wants to merge 10 commits into
examples/credentialsfrom
examples/negative-caching

Conversation

@texastony

@texastony texastony commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Issue #, if available:

Description of changes:

Goal. Build a more complete multi-tenant solution with the AWS KMS Hierarchical Keyring: one that handles a tenant's KMS key becoming unavailable without flooding requests for it. When AWS KMS denies a Branch Key (for example, a 400 AccessDeniedException), the failure is cached. For a configurable timeout, the cache and the components above it stop calling Amazon DynamoDB and AWS KMS for that Branch Key.

Constraint. No changes to the AWS Encryption SDK for Python or the MPL source. Only custom implementations of their public, extendable interfaces are allowed: Keyrings, Master Key Providers, Cryptographic Materials Managers, and Cryptographic Materials Caches. Only the Hierarchical Keyring's behavior is in scope, because it is the multi-tenant solution.

This PR (draft): the design doc only. examples/src/multi_tenant/negative_caching.md records the options considered and the decisions made. Implementation follows in later commits.

  • Traces the Hierarchical Keyring's cache and KMS request paths on encrypt and decrypt, through the specification and MPL 1.11.3. Findings:

    • AWS KMS is only called on a cache miss, and always after a DynamoDB GetItem.
    • The cache is never told when a fetch fails.
    • A cache error other than EntryDoesNotExist fails the keyring before the Key Store is called. This is the hook the design relies on.
  • A custom cache alone is not enough. It cannot see failures, and cannot tell which Branch Key a cache identifier is for. The design pairs:

    • detection in the Tenant KMS Client, which sees the exact KMS error code
    • enforcement in a per-keyring Cache Guard in front of the cache shared by every tenant. The guard is checked before the shared cache, is a lightweight object, and passes through the KMS error it received, as is.
  • Denials are keyed by Branch Key ID, not KMS Key ARN. One KMS key can protect many Branch Keys, and KMS can deny some of them based on their encryption context. A Branch Key's KMS key is also mutable (aws-encryption-sdk-specification#287; the MPL's mutations/mutations TerminalKmsArn). Keying by Branch Key ID also supports the Discovery and MRDiscovery Key Store configurations.

  • What counts as a Denial:

    • the KMS key's state: AccessDeniedException, DisabledException, KMSInvalidStateException, NotFoundException, IncorrectKeyException
    • the tenant's credentials: ExpiredTokenException, UnrecognizedClientException, InvalidSignatureException

    botocore only refreshes credentials proactively, never in response to an expired-token error, so a credential failure that reaches KMS will not resolve by retrying. The doc records a probe of AWS KMS that varies one part of the credentials at a time, and confirms that AWS KMS returns HTTP 400 ExpiredTokenException for expired temporary credentials. Only throttling, server errors, and transport failures are transient.

  • Encrypt and decrypt Denials are treated the same, for every version of the Branch Key.

  • Negative TTL: configured per factory; the default is 5 minutes.

  • A Tenant Keyring Factory replaces the Branch Key ID Supplier. It holds the shared transport, cache, DynamoDB client, Key Store settings, and Negative TTL. Given a Branch Key ID and Tenant Credentials, it vends a Tenant Hierarchical Keyring wired with a Cache Guard and a Tenant KMS Client. The factory and everything it vends MUST be thread safe, and this SHOULD be tested.

  • The Partition ID is revisited. Refreshed credentials share no constant part, so a Partition ID derived from them changes on every refresh. It should be derived from a stable tenant identity instead. design.md 10.2 now notes this; the example's fix is deferred.

Temporary CI changes (fork only). The ci: commits on this branch exist only to allow development on this fork. Forks have no AWS role, CodeBuild project, or Slack webhooks, and the ubuntu-latest runner no longer provides Python 3.8 or 3.9. These commits are temporary and MUST be reverted before this work is proposed upstream.

  • 912ae02:
    • skips the CodeBuild jobs, decrypt_oracle, and the Slack notifications unless the repository owner is aws, and lets pull.yml's gate accept those skips (its daily_ci.yml change is reverted by ded3702)
    • runs ci_tests.yaml on Python 3.12 and Ubuntu only (static analysis stays on 3.10, because the pinned linters predate 3.12)
  • 9787429: marks the offline examples tests local and runs them in CI (exampleslocal, mplexampleslocal).
  • 46359a2: adds a ddblocal marker and CI job. It runs the Hierarchical Keyring examples against DynamoDB Local (amazon/dynamodb-local:3.3.2, as a service container) with the fake KMS, with no AWS credentials.
  • ded3702: only the Pull Request Workflow runs on this fork's PRs.
    • daily_ci.yml is reverted. Editing it made its PR trigger re-run Daily CI on every push; on a fork's schedule, its original guards already skip every job.
    • ci_codebuild-tests.yml keeps only workflow_call, dropping its own push and schedule triggers.

The tests and tox factors from 9787429 and 46359a2 may be worth proposing upstream on their own; the fork guards and the reduced Python and OS matrix should not be.

Out of scope: other keyrings, the KMS Master Key Providers, and the Caching CMM; Amazon DynamoDB failures (the application owns that resource). Propagating a NotFoundException across Branch Key IDs is decided against in Design Question 10.

This branch is based on examples/credentials (aws#827).

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Check any applicable:

  • Were any files moved? Moving files changes their URL, which breaks all hyperlinks to the files.

🤖 Generated with Claude Code

Add negative_caching.md: how a Tenant Hierarchical Keyring stops calling
Amazon DynamoDB and AWS KMS for a Branch Key ID that AWS KMS has denied,
for a configurable time, using only the public extension interfaces.

- Trace the Hierarchical Keyring's cache and KMS paths (spec, MPL 1.11.3).
- Detect Denials in the Tenant KMS Client; enforce them in a per keyring
  Cache Guard in front of the shared cache, checked before it.
- Key Denials by Branch Key ID, not KMS Key ARN, as KMS can deny some
  Branch Keys of one KMS Key by encryption context; supports Discovery.
- A Tenant Keyring Factory replaces the Branch Key ID Supplier.
- Revisit the Partition ID: refreshed credentials share no constant part,
  so it should derive from a stable Tenant identity.

Note in design.md 10.2 that the Partition ID decision is revisited; the
example's fix is deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
texastony and others added 7 commits November 5, 2026 13:58
- Requirement 4: expired credentials are a transient failure.
- Requirement 8: the factory and what it vends MUST be thread safe.
- Negative TTL: configured per factory, default 5 minutes.
- The Cache Guard passes through the KMS error it received, as is,
  and is a lightweight object despite being a cache.
- Encrypt and decrypt Denials are treated the same.
- Move NotFoundException propagation to Design Question 10, noting
  that a Branch Key's KMS Key is mutable (spec PR aws#287; MPL
  mutations/mutations TerminalKmsArn).
- Resolve the open questions with UPDATEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
botocore only refreshes credentials proactively (advisory and mandatory
windows before expiry) and never in response to an expired token error,
so an expired, unrecognized, or mis-signed credential reaching AWS KMS
will not resolve by retrying; left undenied, every request would call
Amazon DynamoDB and AWS KMS.

- Add ExpiredTokenException, UnrecognizedClientException, and
  InvalidSignatureException as Denials (5.1, 5.2).
- Record a probe of AWS KMS through the Tenant KMS Client, varying one
  part of the credentials at a time.
- Note these are usually the application's credential handling, not the
  Tenant's; the passed through error keeps the cause visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A GenerateDataKey request signed with sts:GetSessionToken credentials
one minute after they expired returned HTTP 400 ExpiredTokenException;
replace the note in negative_caching.md 5.1 that asked for this to be
confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Forks have no CI_AWS_ROLE_ARN, CodeBuild project, or Slack webhooks, so
those jobs could only fail there; and the ubuntu-latest runner no longer
provides Python 3.8 or 3.9, whose failures cancelled most of the test
matrix (fail-fast).

- Skip codebuild_batch, codebuild_tests, and decrypt_oracle unless the
  repository owner is aws (pull.yml, push.yml, daily_ci.yml), and guard
  ci_codebuild-tests.yml's own push and schedule triggers. daily_ci.yml's
  guards previously covered only its schedule.
- Allow those skips in pull.yml's alls-green gate.
- Skip the Slack notifications on forks.
- ci_tests.yaml: Python 3.12 on ubuntu-latest only (3.8-3.10 are end of
  life); nocmk on 3.12; drop test-upstream-requirements-py311, which pins
  Python 3.11. fail-fast stays true. Static analysis stays on 3.10, as the
  pinned linters predate 3.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Examples tests only ran in CodeBuild, which forks cannot use, and all
were marked examples, so the offline tests could not be selected apart
from the integration tests.

- Mark the offline examples tests pytest.mark.local, alongside examples;
  they need no network access or credentials.
- Add tox factors exampleslocal (examples/test/legacy/, without the MPL)
  and mplexampleslocal (examples/test/, with the MPL), each -m local.
- Run exampleslocal and mplexampleslocal-mpl in ci_tests.yaml.

Locally with tox 4.25.0: exampleslocal 11 passed; mplexampleslocal-mpl
52 passed; the integration tests are deselected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hierarchical Keyring paths through the AWS KMS Key Store (CreateKey,
VersionKey, and cache misses: DynamoDB plus GenerateDataKeyWithoutPlaintext,
ReEncrypt, and Decrypt) had only run against AWS, with permissions the
test identity lacks. DynamoDB Local, AWS's emulator, with the fake KMS,
runs them with no AWS credentials, so they can run on forks.

- Register a ddblocal pytest marker; the new tests are marked only
  ddblocal, so the examples (CodeBuild) and local runs do not select them.
- examples/test/multi_tenant/test_ddblocal_hierarchical.py: CreateKey
  items and KMS calls; the multi-tenant example end to end, per tenant
  signing; VersionKey and cleanup of every version; and, without negative
  caching, every denied attempt calls DynamoDB GetItem and kms:Decrypt.
- tox: mplddblocal factor; pass DDB_LOCAL_ENDPOINT through.
- ci_tests.yaml: a ddblocal job on Python 3.12 with amazon/dynamodb-local
  3.3.2 as a service container (its default command is -inMemory).

Locally with podman and tox 4.25.0: mplddblocal-mpl 4 passed; no tables
left behind; the local and examples markers select none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Revert daily_ci.yml to examples/credentials. Its pull_request trigger
  only fires for a PR that edits daily_ci.yml, so editing it re-ran Daily
  CI on every push; and on a fork's schedule, its original guards already
  skip every job.
- ci_codebuild-tests.yml: drop the push and schedule triggers, keeping
  workflow_call; it is only called by pull.yml, push.yml, and daily_ci.yml.

Temporary, for development on a fork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
texastony and others added 2 commits November 5, 2026 16:22
…aching

Negative caching (negative_caching.md): while AWS KMS has Denied a Branch
Key ID, its Tenant Hierarchical Keyring fails fast, without calling Amazon
DynamoDB or AWS KMS, for a Negative TTL.

negative_cache.py (no MPL import):
- DENIAL_ERROR_CODES, is_denial, and error_code (Design Question 5).
- BranchKeyNegativeCache: the Negative Cache Entry of one Branch Key ID,
  with record, get, and clear. One lock is held across checking expiry
  and discarding, so get() never erases a Denial recorded concurrently.
  Rejects an empty Branch Key ID and a non-positive or NaN Negative TTL.
- record() logs a warning naming the Branch Key ID, the KMS error code,
  the Negative TTL, and the error's text.

cache_guard.py:
- CacheGuard, an ICryptographicMaterialsCache wrapping the cache shared
  by every Tenant. While Denied, it raises
  OpaqueWithTextError(obj=error, obj_message=str(error)) before consulting
  the shared cache; the Hierarchical Keyring then fails without calling
  the AWS KMS Key Store. Otherwise it delegates.
- OpaqueWithTextError keeps the original error, the same object, and its
  code through the MPL's conversion to and from Dafny, for all eight
  Denial codes; a ComAmazonawsKms error loses both for the KMS errors the
  MPL models. A new wrapper per raise stops the error's traceback growing.

Tests (pytest.mark.local), in examples/test/multi_tenant/: classification,
expiry, replacement, logging, validation; a deterministic expiry race
test; thread safety; the guard's delegation and refusal of cached hits;
and end to end, a real Hierarchical Keyring on the Cache Guard, seeded
with the specification's cache identifiers, failing encrypt and decrypt
with zero DynamoDB and KMS calls while Denied. FakeClock and kms_error
are shared in helpers.py.

negative_caching.md 4.3 and 5.2 record the error wrapping, and that the
first Denial (a real cache miss, confirmed against DynamoDB Local) loses
the code of the KMS errors the MPL models, so the code is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completes negative caching (examples/src/multi_tenant/negative_caching.md,
Design Questions 3.3, 6, 7.2, and 8).

TenantKmsClient(..., negative_cache=None):
- Records a KMS ClientError in its BranchKeyNegativeCache, as the same
  object, only for a Decrypt, only for a Denial (is_denial), and only if
  the encryption context has no branch-key-id or names the negative
  cache's Branch Key ID. Assumes hierarchy version 1 Branch Keys, the
  only version shipped. The error is always re-raised unchanged.

TenantKeyringFactory:
- Holds what every Tenant Hierarchical Keyring shares: the KmsTransport,
  the DynamoDB client, the AWS KMS Key Store configuration, the cache
  limit TTL, the Negative TTL, and the storm tracking cache, which it
  creates from settings (default_storm_tracking_cache(), design.md
  10.1.1) so it can require the grace period be less than ttl_seconds.
- negative_cache_for: one negative cache per (Tenant, Branch Key ID), so
  a Tenant misconfigured with another's Branch Key ID cannot deny it to
  its owner.
- keyring_for: a Tenant KMS Client bound to that negative cache, an AWS
  KMS Key Store, and a Hierarchical Keyring with a Cache Guard over the
  shared cache and the Tenant ID, as is, as its Partition ID.

negative_caching.md:
- Negative caches per Tenant and Branch Key ID; the factory owns the
  storm tracking cache; the Partition ID is the Tenant ID as is.
- The Tenant ID is the authorization boundary of cached materials: vended
  keyrings need not be cached, and the application MUST pass the Tenant it
  authenticated and the Branch Key ID it assigned. Documents the
  alternative of a credentials Partition ID with cache deletion on Denial
  and on keyring cleanup.
- Hierarchy version 2 was never shipped. Open Question 5: lock contention.

Tests:
- Denial recording, the factory offline (zero remote calls, isolation,
  thread safety, the grace period rule), and end to end on DynamoDB Local.
- Stubbed DynamoDB calls are counted on provide-client-params: botocore's
  Stubber rejects a call before before-call, so the Cache Guard's and the
  factory's "no DynamoDB calls" asserts had held vacuously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant