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>/<parameter>
/<app-prefix>/tenants/<tenant>[/<variant>]/<env>/<parameter>
  • The app family holds values shared by every tenant deployment.
  • The tenant family holds values specific to one tenant.
  • Tenant values are loaded second and override app values on conflict.
  • The optional variant segment is an opaque deployment discriminator (e.g. an upstream API major version) interposed between tenant and env.
  • Deeper segments become nested settings: /acme/app/production/database/hostsettings.DATABASE.host.

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

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>/*"
            ]
        },
        {
            "Sid": "OwnTenantParametersOnly",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath"
            ],
            "Resource": [
                "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>/*"
            ]
        }
    ]
}

Both the bare path and the /* glob are listed: GetParametersByPath authorizes against the path argument itself, while GetParameter on a child authorizes against the individual parameter ARN.

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 free of characters requiring escaping (a-z0-9- is a safe set) and never derive them from untrusted input.
  • 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.

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

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__)"
    

Testing

Tests use moto to mock AWS SSM — no Docker, LocalStack, or AWS account is required.

With uv

uv sync
uv run pytest

With Nix

A flake.nix is provided. It supplies a pinned Python interpreter, uv, and ruff, and syncs the project environment on shell entry:

# Enter a development shell (runs `uv sync` automatically):
nix develop
uv run pytest

# Or run the test suite directly, without entering a shell:
nix run .#test

# Pass pytest arguments through:
nix run .#test -- -k tenant -v

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

If you use direnv, a one-line .envrc gets you automatic shell activation:

echo "use flake" > .envrc && direnv allow

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-0.1.0.tar.gz (59.8 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-0.1.0-py3-none-any.whl (8.5 kB view details)

Uploaded Python 3

File details

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

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-0.1.0.tar.gz
Algorithm Hash digest
SHA256 840e82cbc0fbcefd6e4993b5131879abb2f361406fae173362a10777e44d2e77
MD5 a20afc7ad0f5ec922c462bec91eb4702
BLAKE2b-256 9f898640db45306819a481a09feee0d7c323632f94e5bd915320014c79672ef4

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for dynaconf_ssm_tenant_loader-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 705359113b8301e54c244153ce5304fb96272d683ee8aafae0c11ad0bba72c96
MD5 8e4ffe767e77e761c214498213f0693c
BLAKE2b-256 529cba93848faca94e54d67b545e81a4729f38c7eebd47f4d17fcabb23f50ca1

See more details on using hashes here.

Release history Release notifications | RSS feed

2.0.0

2 files

1.2.0

2 files

1.1.0

2 files

1.0.0

2 files

This release

0.1.0 This release

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