dynaconf-ssm-tenant-loader
A Dynaconf custom loader for multi-tenant applications that store secrets in AWS Systems Manager Parameter Store.
Path contract
/<app-prefix>/app/<env>
/<app-prefix>/app/<variant>/<env>
/<app-prefix>/tenants/<tenant>/<env>
/<app-prefix>/tenants/<tenant>/<variant>/<env>
Tiers are read in that order — least specific first — and deep-merged, so a more specific tier overrides a less specific one key by key rather than wholesale.
- The app family holds values shared by every tenant deployment.
- The tenant family holds values specific to one tenant.
- The optional variant segment is an opaque deployment discriminator (e.g. an upstream API major version) interposed between the family and the env. It applies to both families, so shared-but-variant-specific values have a home.
- Family is the dominant dimension: a plain tenant value outranks an app-plus-variant value. Variant refines a family; it does not escape it.
- A tier that does not exist is simply skipped, so a variant deployment still inherits everything from the non-variant tenant and app tiers.
- Deeper segments become nested settings:
/acme/app/production/database/host→settings.DATABASE.host.
Merge semantics
Deep merge applies per key, at every depth. Given:
/acme/app/production/database/host = db.internal
/acme/app/production/database/port = @int 5432
/acme/app/production/database/pool/size = @int 5
/acme/tenants/tenant-a/production/database/host = db.tenant-a.internal
/acme/tenants/tenant-a/v2/production/database/pool/size = @int 20
a deployment with tenant=tenant-a, variant=v2 resolves to:
settings.DATABASE.host == "db.tenant-a.internal" # tenant wins
settings.DATABASE.port == 5432 # inherited from app
settings.DATABASE.pool.size == 20 # variant wins, nested
Lists accumulate rather than replace, per Dynaconf's merge rules: a
list-valued parameter present at two tiers yields the concatenation. Use
Dynaconf's @reset
marker on the more specific value to
override instead of extend.
Variant naming
A variant must be a single path segment and must not collide with an
environment name or a structural segment, since
/acme/tenants/t/staging/production/... is ambiguous with the staging
env tier. The loader rejects, case-insensitively: app, tenants,
default, global, dev, development, stage, staging, prod,
production, test, testing, and the environment being loaded.
SSM_PARAMETER_TENANT_VARIANT_FOR_DYNACONF still requires
SSM_PARAMETER_TENANT_FOR_DYNACONF to be set, even though it now also
affects app-family paths — a variant is a property of a tenant
deployment.
Single-key loads
load(..., key="foo") addresses one leaf parameter via GetParameter
and cannot merge: the most specific tier that has it wins outright. It
therefore cannot retrieve a subtree — key="database" is a miss even if
database/host exists.
Usage
from dynaconf import Dynaconf
settings = Dynaconf(
environments=True,
settings_file="settings.toml",
LOADERS_FOR_DYNACONF=[
"dynaconf_ssm_tenant_loader.loader",
"dynaconf.loaders.env_loader",
],
)
Configuration
Set in the process environment (preferred, avoids chicken/egg with settings files) or in settings:
| Variable | Required | Purpose |
|---|---|---|
SSM_PARAMETER_APP_PREFIX_FOR_DYNACONF |
yes | The <app-prefix> path segment |
SSM_PARAMETER_TENANT_FOR_DYNACONF |
no | Enables the tenant family |
SSM_PARAMETER_TENANT_VARIANT_FOR_DYNACONF |
no | Requires tenant; extra path segment |
SSM_ENDPOINT_URL_FOR_DYNACONF |
no | e.g. LocalStack |
SSM_SESSION_FOR_DYNACONF |
no | Custom boto3.session.Session kwargs |
All three path-segment settings are stripped of surrounding whitespace
before use, and the normalized value is written back to the settings
object so settings.inspect() agrees with the paths actually queried.
Internal whitespace is an error rather than a silent miss. Tenant and
variant must each be a single path segment; the app prefix may span
several (acme/team-b).
Tenant isolation via IAM
Each tenant deployment should run under its own IAM role (Lambda execution role, ECS task role, EC2 instance profile, etc.). Grant that role only:
- The app-family path for its environment.
- Its own tenant-family leaf path for its environment.
Read policy template (per tenant, per environment)
Substitute <REGION>, <ACCOUNT_ID>, <APP_PREFIX>, <TENANT>,
<VARIANT> (omit the segment entirely if unused), and <ENV>:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AppSharedParameters",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": [
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/app/<ENV>",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/app/<ENV>/*",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/app/<VARIANT>/<ENV>",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/app/<VARIANT>/<ENV>/*"
]
},
{
"Sid": "OwnTenantParametersOnly",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": [
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/tenants/<TENANT>/<ENV>",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/tenants/<TENANT>/<ENV>/*",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/tenants/<TENANT>/<VARIANT>/<ENV>",
"arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/tenants/<TENANT>/<VARIANT>/<ENV>/*"
]
}
]
}
Omit the <VARIANT> ARN pairs entirely if you do not use variants.
Both the bare path and the /* glob are listed for each tier:
GetParametersByPath authorizes against the path argument itself, while
GetParameter on a child authorizes against the individual parameter ARN.
Partial grants are fine. The loader probes every tier in the
contract, so a role scoped to a subset will see AccessDeniedException
on the rest. Those denials are logged at WARNING and skipped — they
never fail the load, even with silent=False, since with four tiers a
narrow grant is the normal case rather than a misconfiguration. Genuine
errors (throttling, connectivity) still propagate when silent=False.
Denials are indistinguishable from absent tiers in effect, so if a value
mysteriously fails to appear, check the warnings before checking the
parameter names.
Note also that a configured variant doubles the number of
GetParametersByPath calls per load, from two to four.
KMS permissions for SecureString parameters
SecureString values encrypted with the AWS-managed key (aws/ssm)
require no extra statement. If you use a customer-managed KMS key, the
role also needs:
{
"Sid": "DecryptSecureStrings",
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:<REGION>:<ACCOUNT_ID>:key/<KEY_ID>"
}
For defense in depth, encrypt each tenant's parameters with a
per-tenant customer-managed key and scope each role's kms:Decrypt
to its own key. Then even a misconfigured SSM grant yields only
ciphertext.
Writer/administration policy
Runtime roles never write parameters. Seeding and rotation should be done by a separate CI or administrative principal, e.g.:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageAppParameterTree",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:DeleteParameters",
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:AddTagsToResource",
"ssm:ListTagsForResource"
],
"Resource": "arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/*"
}
]
}
This principal must not be assumable by tenant runtime roles.
Note:
ssm:DescribeParametersonly supportsResource: "*"in some contexts and reveals parameter names (not values) across the account. It is not required by this loader at runtime — the loader uses onlyGetParameterandGetParametersByPath— so leave it off runtime roles.
Warnings
- Never grant a runtime role
/<APP_PREFIX>or/<APP_PREFIX>/tenants. SSM path permissions are hierarchical: a principal allowed to read a parent path can read all descendants via recursiveGetParametersByPath, and an explicitDenyon a child does not reliably block enumeration through an allowed parent. Always allow-list leaf paths only, as in the template above. - Tenant and environment names become IAM resource ARN segments. Keep
them to
a-z0-9-, and never derive them from untrusted input. The loader rejects whitespace outright, since a stray space desyncs the path from the grant and surfaces as an empty load rather than an authorization error — but it does not police the rest of the character set. - If multiple environments share one AWS account, the
<ENV>segment in the resource ARN is what separates them — a production role granted<ENV>=productioncannot readstagingparameters, and vice versa. - Never grant a runtime role
/<APP_PREFIX>/tenants/<TENANT>(the bare tenant path, without an env or variant segment). Recursive reads from there cross environment boundaries, defeating the<ENV>separation described above.
Development
Environment setup
The project uses uv for dependency management. Either use uv directly:
uv sync
or use the provided Nix flake, which supplies a pinned Python
interpreter, uv, and ruff, and syncs the environment on shell entry:
nix develop
The flake pins uv to the Nix-provided interpreter
(UV_PYTHON_DOWNLOADS=never), so no standalone Python builds are downloaded.
The virtual environment is created in-project at .venv/.
direnv
If you use direnv, create a .envrc in the
project root:
use flake
dotenv_if_exists .env
Then allow it once:
direnv allow
-
use flakeactivates the Nix development shell automatically whenever you enter the directory (requires nix-direnv for caching, strongly recommended). -
dotenv_if_exists .envloads a local, untracked.envfile into your shell if one is present, and is silently skipped otherwise. This is useful for local experimentation against real or emulated AWS, e.g.:# .env — local only, never commit AWS_PROFILE=my-dev-profile AWS_DEFAULT_REGION=us-east-1 SSM_PARAMETER_APP_PREFIX_FOR_DYNACONF=acme SSM_PARAMETER_TENANT_FOR_DYNACONF=tenant-a
Ensure .env is in .gitignore — it may contain credentials and must
never be committed. (.envrc itself contains no secrets and is safe to
commit.)
Note that the test suite does not need any of these variables: the
aws_credentials fixture sets fake credentials and clears the loader's
environment variables so host configuration cannot leak into tests.
Linting and formatting
Ruff handles both:
uv run ruff check .
uv run ruff format --check .
Inside the Nix shell, ruff is on PATH directly, so plain
ruff check . also works.
Running tests
uv run pytest
# or, without entering a shell:
nix run .#test
# pass pytest arguments through:
nix run .#test -- -k tenant -v
Tests use moto to mock AWS SSM — no
Docker, LocalStack, or AWS account required.
Releasing
The canonical version lives in pyproject.toml; the package exposes it
at runtime via importlib.metadata, so it is bumped in exactly one
place.
-
Update
versioninpyproject.tomland note the changes in the changelog. -
Commit and tag:
git commit -am "Release v0.2.0" git tag -a v0.2.0 -m "v0.2.0" git push --follow-tags
-
Build the sdist and wheel:
uv buildArtifacts are written to
dist/. -
Publish to PyPI:
uv publishuv publishreads credentials fromUV_PUBLISH_TOKEN(a PyPI API token). For CI-driven publishing, prefer PyPI trusted publishing from a GitHub Actions workflow triggered on the tag, which removes the need for a long-lived token entirely. -
Verify the release installs cleanly:
uv run --with dynaconf-ssm-tenant-loader --no-project \ python -c "import dynaconf_ssm_tenant_loader as m; print(m.__version__)"
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dynaconf_ssm_tenant_loader-1.2.0.tar.gz.
File metadata
- Download URL: dynaconf_ssm_tenant_loader-1.2.0.tar.gz
- Upload date:
- Size: 52.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
87283b4937bfc25d6c294bf33e48bb775aa2f95997a6972a3f6a630209a556a0
|
|
| MD5 |
d2addfcd14c695679740ab05826e4f69
|
|
| BLAKE2b-256 |
695e5d0ad84a25d6da6eb8cf076aff3fcd402760d17bfb1dc2906bed8368653c
|
Provenance
The following attestation bundles were made for dynaconf_ssm_tenant_loader-1.2.0.tar.gz:
Publisher:
release.yml on arcature/dynaconf-ssm-tenant-loader
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dynaconf_ssm_tenant_loader-1.2.0.tar.gz -
Subject digest:
87283b4937bfc25d6c294bf33e48bb775aa2f95997a6972a3f6a630209a556a0 - Sigstore transparency entry: 2439575595
- Sigstore integration time:
-
Permalink:
arcature/dynaconf-ssm-tenant-loader@7a28143c61b6d74c954a892e9c0382c709bbbc37 -
Branch / Tag:
refs/tags/v1.2.0 - Owner: https://github.com/arcature
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7a28143c61b6d74c954a892e9c0382c709bbbc37 -
Trigger Event:
push
-
Statement type:
File details
Details for the file dynaconf_ssm_tenant_loader-1.2.0-py3-none-any.whl.
File metadata
- Download URL: dynaconf_ssm_tenant_loader-1.2.0-py3-none-any.whl
- Upload date:
- Size: 11.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
64c01a2e2b1e2fa4934a6d9c9e9532c9d9ab8a3088ee424c52f34b0292221878
|
|
| MD5 |
333ddf1f2df6578a7c210b7efc594acc
|
|
| BLAKE2b-256 |
20e66c6f47695ed1f00b6236ec87cf065c6e266f5d76cd230fb1d601d49d43c0
|
Provenance
The following attestation bundles were made for dynaconf_ssm_tenant_loader-1.2.0-py3-none-any.whl:
Publisher:
release.yml on arcature/dynaconf-ssm-tenant-loader
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dynaconf_ssm_tenant_loader-1.2.0-py3-none-any.whl -
Subject digest:
64c01a2e2b1e2fa4934a6d9c9e9532c9d9ab8a3088ee424c52f34b0292221878 - Sigstore transparency entry: 2439575678
- Sigstore integration time:
-
Permalink:
arcature/dynaconf-ssm-tenant-loader@7a28143c61b6d74c954a892e9c0382c709bbbc37 -
Branch / Tag:
refs/tags/v1.2.0 - Owner: https://github.com/arcature
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7a28143c61b6d74c954a892e9c0382c709bbbc37 -
Trigger Event:
push
-
Statement type: