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>/<env>/app/default
/<app-prefix>/<env>/app/<variant>
/<app-prefix>/<env>/tenants/<tenant>/default
/<app-prefix>/<env>/tenants/<tenant>/<variant>
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 (an upstream API major version, a PR review app, ...). It applies to both families, so shared-but-variant-specific values have a home.
- The environment is the outermost segment because it is the IAM
boundary: a role granted
/<app-prefix>/<env>/...can read every present and future variant tier without policy changes. Variants come and go; grants do not. - The
defaultleaf is reserved for the non-variant tier so that no tier is a path-prefix of another. A recursive read of one tier can therefore never swallow a sibling variant's parameters. - 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
defaulttenant and app tiers. - Deeper segments become nested settings:
/acme/production/app/default/database/host→settings.DATABASE.host.
Migrating from 1.x: the 1.x contract (
/<app-prefix>/app/[<variant>/]<env>) is not read by this version. Re-seed parameters under the new layout and update IAM policies; there is no dual-read mode.
Merge semantics
Deep merge applies per key, at every depth. Given:
/acme/production/app/default/database/host = db.internal
/acme/production/app/default/database/port = @int 5432
/acme/production/app/default/database/pool/size = @int 5
/acme/production/tenants/tenant-a/default/database/host = db.tenant-a.internal
/acme/production/tenants/tenant-a/v2/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 the
reserved default leaf or a structural segment. The loader rejects,
case-insensitively: default, app, tenants. Anything else is fair
game — with the environment ahead of the variant slot, environment
names can no longer make a path ambiguous, so CI-generated names like
pr-1234 need no coordination with env naming.
SSM_PARAMETER_TENANT_VARIANT_FOR_DYNACONF requires
SSM_PARAMETER_TENANT_FOR_DYNACONF to be set, even though it 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.), one per (tenant,
environment) pair. Variants need no roles or policy changes of their
own: the grants below cover the default leaf and every variant tier.
Read policy template (per tenant, per environment)
Substitute <REGION>, <ACCOUNT_ID>, <APP_PREFIX>, <ENV>, and
<TENANT>:
{
"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>/<ENV>/app/*"
},
{
"Sid": "OwnTenantParametersOnly",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": "arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/<APP_PREFIX>/<ENV>/tenants/<TENANT>/*"
}
]
}
A single glob per family suffices because every tier path — default
and variants alike — nests strictly below .../app or
.../tenants/<TENANT>. GetParametersByPath authorizes against the
path argument (e.g. /<APP_PREFIX>/<ENV>/app/default), and
GetParameter against the individual parameter ARN; both match the
glob. Unlike bare parent-path grants, this glob is safe: it never
crosses the tenant or environment boundary, because both sit outside
it in the path.
Partial grants are fine. The loader probes every tier in the
contract, so a role scoped narrower than the template (e.g. app-family
only) will see AccessDeniedException on the rest. Those denials are
logged at WARNING and skipped — they never fail the load, even with
silent=False. 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>,/<APP_PREFIX>/<ENV>, or/<APP_PREFIX>/<ENV>/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. The template's two globs are the widest safe grants. - If multiple environments share one AWS account, the
<ENV>segment is what separates them. It is the outermost segment precisely so that the per-tenant glob above cannot cross it: a production role granted<ENV>=productioncannot readstagingparameters, and vice versa. - 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. - Variants are not a security boundary: any code running under a
(tenant, env) role can read every variant's parameters for that
tenant and env. If variants are PR review apps executing unreviewed
code, be deliberate about what the shared
defaulttiers contain.
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-2.0.0.tar.gz.
File metadata
- Download URL: dynaconf_ssm_tenant_loader-2.0.0.tar.gz
- Upload date:
- Size: 56.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 |
243e84314e971458e99f949830c7e9a504f45465fa5893347a04daa0ee534008
|
|
| MD5 |
1a48b7fb91e9d46ea40b1fa25d15ccd1
|
|
| BLAKE2b-256 |
5e2ab0f121f2ba2dbb5e1503d14b11b11686315550a1c020795645fe990ba9ec
|
Provenance
The following attestation bundles were made for dynaconf_ssm_tenant_loader-2.0.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-2.0.0.tar.gz -
Subject digest:
243e84314e971458e99f949830c7e9a504f45465fa5893347a04daa0ee534008 - Sigstore transparency entry: 2455563692
- Sigstore integration time:
-
Permalink:
arcature/dynaconf-ssm-tenant-loader@cc2d8d4ff68f28e2940efbf5b2fcc8ec1972d53e -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/arcature
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cc2d8d4ff68f28e2940efbf5b2fcc8ec1972d53e -
Trigger Event:
push
-
Statement type:
File details
Details for the file dynaconf_ssm_tenant_loader-2.0.0-py3-none-any.whl.
File metadata
- Download URL: dynaconf_ssm_tenant_loader-2.0.0-py3-none-any.whl
- Upload date:
- Size: 12.1 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 |
7b6638dd28579b1edf6e535dfcf220b10177be9a47c609ade1ad6d1315b969ee
|
|
| MD5 |
cf4f0ec7b11f85f35f491489801ba491
|
|
| BLAKE2b-256 |
44f7dba92900dd996975d42b99fc8b45b778194e0d43f9dfa69ca7a9fea43dd5
|
Provenance
The following attestation bundles were made for dynaconf_ssm_tenant_loader-2.0.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-2.0.0-py3-none-any.whl -
Subject digest:
7b6638dd28579b1edf6e535dfcf220b10177be9a47c609ade1ad6d1315b969ee - Sigstore transparency entry: 2455563825
- Sigstore integration time:
-
Permalink:
arcature/dynaconf-ssm-tenant-loader@cc2d8d4ff68f28e2940efbf5b2fcc8ec1972d53e -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/arcature
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cc2d8d4ff68f28e2940efbf5b2fcc8ec1972d53e -
Trigger Event:
push
-
Statement type: