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.
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 }
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.
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_namevariable 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
Tenanttag 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.
Related
- Module Design Patterns — the parent topic on module boundaries and contracts
- Versioning and Publishing Private GIS Terraform Modules — how the versions this interface depends on are released
- IAM Role Mapping for GIS — the identity boundaries a tenant policy attaches to
- PostGIS Cluster Provisioning — provisioning the shared cluster this module attaches tenants to