Designing Module Interfaces for Multi-Tenant GIS Platforms

A multi-tenant GIS platform hosts many customers’ geometry on shared infrastructure, and the module interface is where the isolation guarantee is either expressed or lost. A module that takes tenant_id and quietly uses it as a naming suffix has documented an intention; a module that takes tenant_id and derives the schema, the bucket prefix, the role boundary and the tag set from it has implemented one. This guide extends Module Design Patterns within Spatial IaC Architecture and Fundamentals to the specific problem of designing an interface that makes cross-tenant leakage structurally difficult rather than merely discouraged.

The isolation decision the interface must encode

Before any variable is named, decide where the tenancy boundary sits, because it determines the entire interface. Three models recur in spatial platforms and they are not interchangeable.

Shared cluster, schema per tenant. One PostGIS instance, one database, a schema per tenant, one bucket prefix per tenant. Cheapest, densest, and the model where an interface mistake is most costly, because a query that forgets to qualify its schema reads someone else’s parcels. The module must therefore own the schema name and the role’s search_path, never accept them as free text.

Shared cluster, database per tenant. Stronger isolation at the connection level and a much smaller blast radius for a mistaken grant, at the cost of connection-pool pressure — every tenant’s pool is a separate set of connections against one max_connections budget, which interacts directly with the arithmetic described in Vector Tile Service Provisioning.

Cluster per tenant. Complete isolation, straightforward compliance story, and a cost curve that only works for a small number of large tenants. The interface here is nearly the single-tenant module with a tenant tag.

Three tenancy models for a shared spatial platform Schema per tenant places every tenant in one database on one cluster, separated by schema and bucket prefix; it is the densest and cheapest model and the one where an unqualified query reads another tenant's data. Database per tenant places each tenant in its own database on a shared cluster, isolating at the connection level but dividing one connection budget among all tenants. Cluster per tenant gives each tenant dedicated infrastructure with complete isolation and a cost profile that only suits a small number of large tenants. Schema per tenant one cluster, one database tenant_a · tenant_b · tenant_c densest and cheapest largest blast radius Database per tenant one cluster, many databases isolation at connection level smaller blast radius one connection budget, split Cluster per tenant dedicated infrastructure complete isolation simple compliance story only a few large tenants The model decides the interface: what the module owns, what it accepts, and what it must refuse.

Prerequisites and interface rules

Terraform 1.6 or later with providers pinned; a registry or repository for the shared module, versioned as described in Versioning and Publishing Private GIS Terraform Modules; and a decision recorded about which tenancy model this module implements, because a module that tries to support all three ends up enforcing none.

Four rules make an interface safe for multi-tenancy, and each of them is a restriction on what the caller may say.

Derive, do not accept. Take tenant_id and derive the schema name, bucket prefix, role name and tag set inside the module. A caller who can pass schema_name directly can pass another tenant’s schema, and no amount of documentation prevents it. Every identifier that carries tenancy meaning should be computed from one input.

Validate the identifier at the boundary. A tenant_id that permits arbitrary characters becomes a path traversal in a bucket prefix and a quoting problem in a schema name. Constrain it to a short lowercase alphanumeric pattern with a validation block, so an unusable identifier fails at plan time rather than producing a resource with a surprising name.

Make tags a contract, not a suggestion. Merge a module-owned tag map over any caller-supplied one, so a caller cannot override Tenant while still adding their own labels. Cost allocation, access policy conditions and incident scoping all depend on that tag being trustworthy, which is the same argument made in FinOps Tagging Strategies for Geospatial Resources.

Return a narrow output surface. Export the connection reference and the prefix a tenant’s workload needs, not the cluster identifier or the shared key ARN. Every output is an affordance, and an output that exposes a shared resource invites a caller to attach something to it outside the module’s control.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = "~> 1.22"
    }
  }
}

variable "tenant_id" {
  type        = string
  description = "Short tenant identifier. Every tenant-scoped name derives from this."
  validation {
    # Unconstrained input here becomes a path traversal in a bucket prefix and a
    # quoting problem in a schema name.
    condition     = can(regex("^[a-z][a-z0-9]{2,15}$", var.tenant_id))
    error_message = "tenant_id must be 3-16 lowercase alphanumerics starting with a letter."
  }
}

variable "extra_tags" {
  type    = map(string)
  default = {}
}

locals {
  # Derived, never accepted. A caller cannot name another tenant's schema.
  schema  = "tenant_${var.tenant_id}"
  prefix  = "tenants/${var.tenant_id}/"
  role    = "gis_${var.tenant_id}_rw"

  # Module-owned tags win: merge order puts them last.
  tags = merge(var.extra_tags, {
    Tenant    = var.tenant_id
    ManagedBy = "terraform"
    Module    = "gis-tenant/v3"
  })
}

resource "postgresql_schema" "tenant" {
  name  = local.schema
  owner = postgresql_role.tenant.name
  # No PUBLIC grant: visibility is granted explicitly to the tenant role only.
  policy {
    role   = postgresql_role.tenant.name
    usage  = true
    create = true
  }
}

resource "postgresql_role" "tenant" {
  name     = local.role
  login    = true
  password = random_password.tenant.result
  # search_path is set by the module so an unqualified query resolves inside
  # the tenant's own schema rather than falling through to a shared one.
  search_path = [local.schema]
}

resource "aws_iam_policy" "tenant_objects" {
  name = "gis-${var.tenant_id}-objects"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:PutObject"]
      # The prefix is derived and terminal — no wildcard above the tenant root.
      Resource = "${var.shared_bucket_arn}/${local.prefix}*"
    }]
  })
}

variable "shared_bucket_arn" { type = string }

# A narrow output surface: what the tenant's workload needs, nothing shared.
output "schema_name" { value = local.schema }
output "object_prefix" { value = local.prefix }
output "role_secret_arn" { value = aws_secretsmanager_secret.tenant.arn }
One input, four derived names, a narrow output surface A single validated tenant identifier enters the module. From it the module derives four names: the PostGIS schema, the database role together with the search path that scopes unqualified queries into that schema, the object storage prefix, and the tag set used for cost allocation and policy conditions. The module exports only three values — the schema name, the object prefix and the reference to the credential secret. The shared cluster identifier and the shared key are deliberately not exported, because every output is an affordance for a caller to attach something outside the module's control. tenant_id validated pattern schema tenant_<id> role + search_path prefix tenants/<id>/ tags, module-owned Outputs schema_name object_prefix role_secret_arn never the shared cluster id

Verification

Prove the isolation rather than assuming it. Connect as one tenant’s role and attempt SELECT * FROM tenant_other.parcels; the expected result is a permission error, and anything else is a finding. Attempt an object read one level above the tenant prefix and expect access denied. Run terraform plan with a tenant_id containing a slash or an uppercase letter and confirm the validation rejects it before any resource is proposed.

Then verify the derived names actually appear where they should: the tag on every created resource, the prefix on the policy resource string, and the search_path on the role. A module that derives correctly but forgets to apply one of the four gives an isolation guarantee with a hole in it, and the hole is invisible until a tenant finds it.

Five probes that state the isolation guarantee as tests Running as tenant A, reading and writing tenant A's own schema must succeed and reading and writing tenant A's own object prefix must succeed. Selecting from tenant B's schema must be refused, reading an object under tenant B's prefix must be refused, and listing one level above the tenant root must be refused. Those five results together are the isolation guarantee expressed as something executable, and any deviation is a finding rather than a curiosity. Probe, running as tenant A Required result SELECT FROM tenant_a.parcels succeeds PUT tenants/a/derived/run.tif succeeds SELECT FROM tenant_b.parcels refused GET tenants/b/... and LIST tenants/ refused

Preventing recurrence

  • Add the isolation probe to the test suite. A cross-tenant read attempt that must fail belongs in the integration layer described in Testing and Validation for Spatial IaC, so a refactor that loosens a grant fails the build.
  • Forbid tenant-scoped names as inputs in review. A pull request adding a schema_name variable to this module is reintroducing the failure the derivation exists to prevent; make that an explicit review rule rather than relying on someone noticing.
  • Pin the module version per tenant deployment. Rolling every tenant onto a new module version simultaneously turns a subtle interface change into a platform-wide event; staged version adoption per tenant contains it.
  • Enforce the tag contract in policy. A rule that fails any plan creating a tenant-scoped resource without a Tenant tag closes the cost-allocation and access-condition gap at the same time.

Frequently Asked Questions

Should the module create the shared cluster as well?

No. A tenant module that can create shared infrastructure will eventually create a second copy of it, and destroying one tenant then risks destroying a shared resource. Provision the cluster separately and pass its reference in, keeping the tenant module’s blast radius equal to one tenant.

How do I onboard a tenant without an apply that touches every other tenant?

Give each tenant its own state — a workspace or a distinct state key — so an onboarding apply plans one tenant’s resources only. A single configuration holding a map of all tenants means every onboarding produces a plan covering the whole estate, which is both slow and risky.

Is a `search_path` really enough to stop cross-tenant reads?

No, and it is not intended to be. search_path resolves unqualified names into the tenant’s own schema, which prevents accidents; the actual boundary is the grant, which must not include other schemas. Both are required, and the probe described under verification tests the grant rather than the path.

What changes when a tenant needs to be deleted?

The narrow output surface pays off here: because the module owns every derived name, a destroy removes exactly the tenant’s schema, role, policy and prefix objects and cannot reach shared resources. Verify beforehand that the object prefix is included in whatever retention or legal hold applies, since a destroy that removes geometry a contract requires you to keep is the more likely mistake.