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>/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/hostsettings.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:

  1. The app-family path for its environment.
  2. 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: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> or /<APP_PREFIX>/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. 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>=production cannot read staging parameters, 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 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-1.2.0.tar.gz (52.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-1.2.0-py3-none-any.whl (11.7 kB view details)

Uploaded Python 3

File details

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

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-1.2.0.tar.gz
Algorithm Hash digest
SHA256 87283b4937bfc25d6c294bf33e48bb775aa2f95997a6972a3f6a630209a556a0
MD5 d2addfcd14c695679740ab05826e4f69
BLAKE2b-256 695e5d0ad84a25d6da6eb8cf076aff3fcd402760d17bfb1dc2906bed8368653c

See more details on using hashes here.

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

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-1.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 64c01a2e2b1e2fa4934a6d9c9e9532c9d9ab8a3088ee424c52f34b0292221878
MD5 333ddf1f2df6578a7c210b7efc594acc
BLAKE2b-256 20e66c6f47695ed1f00b6236ec87cf065c6e266f5d76cd230fb1d601d49d43c0

See more details on using hashes here.

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

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

Release history Release notifications | RSS feed

2.0.0

2 files

This release

1.2.0 This release

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