Skip to main content

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 default leaf 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 default tenant and app tiers.
  • Deeper segments become nested settings: /acme/production/app/default/database/hostsettings.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:DescribeParameters only supports Resource: "*" in some contexts and reveals parameter names (not values) across the account. It is not required by this loader at runtime — the loader uses only GetParameter and GetParametersByPath — 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 recursive GetParametersByPath, and an explicit Deny on 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>=production cannot read staging parameters, 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 default tiers 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 flake activates the Nix development shell automatically whenever you enter the directory (requires nix-direnv for caching, strongly recommended).

  • dotenv_if_exists .env loads a local, untracked .env file 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.

  1. Update version in pyproject.toml and note the changes in the changelog.

  2. Commit and tag:

    git commit -am "Release v0.2.0"
    git tag -a v0.2.0 -m "v0.2.0"
    git push --follow-tags
    
  3. Build the sdist and wheel:

    uv build
    

    Artifacts are written to dist/.

  4. Publish to PyPI:

    uv publish
    

    uv publish reads credentials from UV_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.

  5. 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

dynaconf_ssm_tenant_loader-2.0.0.tar.gz (56.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dynaconf_ssm_tenant_loader-2.0.0-py3-none-any.whl (12.1 kB view details)

Uploaded Python 3

File details

Details for the file dynaconf_ssm_tenant_loader-2.0.0.tar.gz.

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-2.0.0.tar.gz
Algorithm Hash digest
SHA256 243e84314e971458e99f949830c7e9a504f45465fa5893347a04daa0ee534008
MD5 1a48b7fb91e9d46ea40b1fa25d15ccd1
BLAKE2b-256 5e2ab0f121f2ba2dbb5e1503d14b11b11686315550a1c020795645fe990ba9ec

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dynaconf_ssm_tenant_loader-2.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7b6638dd28579b1edf6e535dfcf220b10177be9a47c609ade1ad6d1315b969ee
MD5 cf4f0ec7b11f85f35f491489801ba491
BLAKE2b-256 44f7dba92900dd996975d42b99fc8b45b778194e0d43f9dfa69ca7a9fea43dd5

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 files

1.2.0

2 files

1.1.0

2 files

1.0.0

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page