Two Tenants in One Apply: Workspaces as Code
Multi-tenancy on a data platform is usually a ticket and a week of console clicking. We made it a Terraform apply: a workspace, its catalog, its groups, its grants, and its dbt project — isolated by construction, reviewed in a pull request.
Multi-tenancy is where most data platforms get quietly expensive. A new team wants a space of their own — their own catalog, their own data, their own people, and a wall between them and everyone else. On a typical stack that’s a ticket, a meeting, and a week of someone clicking through admin consoles getting the grants almost right.
We made it an apply.
A tenant on this platform is a workspace. A workspace owns a catalog, a set of groups, and the grants that bind them. The Terraform provider treats all of it as resources, so a whole tenant — isolated, governed, reproducible — fits in a few blocks of HCL and lands in one terraform apply. This post is the shape of that, grounded in the two-team demo that ships with the provider.
If you haven’t seen the provider itself, read Setting It All Up With Terraform first. This is the multi-tenant layer on top.
One thing to understand first
A workspace is server-authoritative. You pick a slug; the platform derives the rest — a primary catalog named ws_<slug>, a members group ws-<slug>-members, an admins group ws-<slug>-admins. You don’t get to name those, and that’s the point. Isolation you can rename is isolation you can get wrong.
The provider knows the derivation rules, so it computes those names locally instead of round-tripping the API for them. That keeps the Terraform dependency graph intact: a grant that references a workspace’s member group is wired to the workspace resource, not to a data source lookup that runs at the wrong time.
The steps, in HCL
1. The workspace and its people
resource "chameleon_workspace" "team_a" { slug = "team-a" display_name = "Team A"}
resource "chameleon_workspace_admin" "lead" { workspace_slug = chameleon_workspace.team_a.slug username = chameleon_user.team_a_lead.username}
resource "chameleon_workspace_member" "dev" { workspace_slug = chameleon_workspace.team_a.slug username = chameleon_user.team_a_dev.username}The workspace is an idempotent upsert — the slug is its identity, immutable, and re-applying a workspace that already exists is a no-op, not an error. Admins administer; members use. Those map to the two derived groups.
2. Namespaces and grants, scoped to the workspace
Here’s the ergonomic part. Catalog-family resources — namespaces, tables, grants — take a workspace_slug instead of a raw catalog. The provider resolves it to the workspace’s own catalog. You say which tenant; it figures out the warehouse.
resource "chameleon_namespace" "public" { workspace_slug = chameleon_workspace.team_a.slug name = "public"}
resource "chameleon_namespace" "limited" { workspace_slug = chameleon_workspace.team_a.slug name = "limited"}
# Everyone in the workspace can read the public namespace.resource "chameleon_grant" "public_to_members" { workspace_slug = chameleon_workspace.team_a.slug namespace = chameleon_namespace.public.name table = "*" privilege = "SELECT" grantee_type = "GROUP" grantee_name = chameleon_workspace.team_a.member_group effect = "allow"}
# Only admins can read the limited namespace.resource "chameleon_grant" "limited_to_admins" { workspace_slug = chameleon_workspace.team_a.slug namespace = chameleon_namespace.limited.name table = "*" privilege = "SELECT" grantee_type = "GROUP" grantee_name = chameleon_workspace.team_a.admin_group effect = "allow"}Two namespaces, two audiences. public for the whole team, limited for admins only. The grants reference the derived groups by attribute, so the access model is wired into the graph — not a string you typed and hoped matched.
3. Tables
resource "chameleon_table" "events" { workspace_slug = chameleon_workspace.team_a.slug namespace = chameleon_namespace.public.name name = "events" columns = [ { name = "event_id", type = "long", required = true }, { name = "occurred_at", type = "timestamp" }, { name = "payload", type = "string" }, ]}4. dbt, owned by the workspace
A dbt connection and project are always workspace-scoped — that’s enforced server-side, not a convention. The connection holds the git credentials; the project points at the repo. Both carry the workspace_slug, and the provider sends an X-Workspace header so the platform binds them to the right tenant.
resource "chameleon_connection" "dbt" { name = "team-a-dbt" type = "github" url = var.dbt_repo_url credentials = var.dbt_git_credentials workspace_slug = chameleon_workspace.team_a.slug}
resource "chameleon_dbt_project" "dbt" { name = "team-a-dbt" repo_url = var.dbt_repo_url repo_path = "dbt" default_branch = "main" connection_id = chameleon_connection.dbt.id workspace_slug = chameleon_workspace.team_a.slug # dbt writes into the workspace's own catalog (ws_team_a). The platform derives # this; we set it explicitly so the plan states where models land. warehouse = "ws_team_a"}dbt builds its models into the tenant’s own catalog. A workspace can’t accidentally write into another’s — the warehouse is the workspace’s primary catalog, and the platform won’t take a different one from the request body.
What one apply gives you
Wrap the blocks above in a module, call it twice with different slugs, and terraform apply once. You get two tenants — team-a and team-b — each with its own catalog on its own bucket, its own public/limited split, its own groups and grants, its own dbt project. No shared state. No grant that leaked across the wall because someone fat-fingered a group name in a console.
Then terraform plan again. It says No changes. That sentence is the whole pitch for infrastructure as code: the tenant is exactly what the HCL says, and you can prove it.
The edges, honestly
Three things are worth knowing before you demo this.
Query engine discovery. A freshly-created workspace catalog isn’t queryable until the engine knows about it. We run SQE with catalog_discovery = "polaris-auto", which discovers any catalog on first reference using the caller’s token. Without it, only the statically-listed warehouse resolves and a workspace query fails with “unknown catalog.” It’s one config line, but it’s load-bearing.
Loading data. Table-load stages files through one bucket named by TABLE_LOAD_STAGING_BUCKET. Point it at a Terraform-managed bucket and the upload path works; leave it unset and uploads return 503. The bucket is per-platform, not per-workspace — one staging area, many tenants.
Cross-tenant sharing. Read-only catalog attachment across workspaces exists in the provider (chameleon_workspace_catalog_attachment), but it’s gated behind a platform flag. Off by default — isolation is the default, sharing is the exception you opt into.
Why do it this way
Because a tenant is a unit of trust, and trust should be reviewable. When a workspace, its catalog, its groups, and its grants are all in one pull request, you can see the blast radius before you apply it. Who can read the limited namespace? It’s right there, referencing a group, in a diff someone approved.
The alternative is what most teams live with: a tenant assembled by hand across six screens, its real access model knowable only by clicking through every one of them. We’d rather read it.