tenant-onboarding-test-author
Workflow-driven skill that authors a test suite for tenant provisioning and offboarding: account creation, isolation at creation (no cross-tenant bleed from a new tenant's first API call), default resource quotas, billing record linkage, seed and default data correctness, idempotent re-provisioning, and teardown with full data deletion. Walks through mapping provisioning surfaces, generating test cases per surface, emitting the test suite skeleton (pytest / Jest / JUnit / Go test), and producing a coverage matrix. Use when a new tenant onboarding flow is introduced or changed, when the offboarding pipeline is modified, or when auditing provisioning coverage before a compliance review. Distinct from cross-tenant-data-leak-tests (leak-test planning + runtime CI gate): this skill covers the provisioning lifecycle, not steady-state access control.
Install with skills.sh (any agent)
npx skills add testland/qa --skill tenant-onboarding-test-authortenant-onboarding-test-author
Overview
Tenant onboarding and offboarding are lifecycle transitions, not steady-state access patterns. A new tenant's provisioning pipeline touches account records, isolation boundaries, quota tables, billing systems, seed data loaders, and delete-cascade paths - surfaces the runtime isolation test battery never exercises.
This skill produces a test suite for that lifecycle. The output is committed to the project repo alongside the runtime suite from cross-tenant-data-leak-tests.
The workflow is:
Differentiation
| Skill | What it tests |
|---|---|
tenant-onboarding-test-author (this skill) | Provisioning lifecycle: creation, quota, billing, seed data, idempotency, teardown |
cross-tenant-data-leak-tests | Steady-state runtime: cross-tenant access attempts, IDOR, horizontal escalation |
cross-tenant-data-leak-tests | CI gate: confirms isolation invariants pass before merge |
How to use
Step 1 - Map provisioning pipeline surfaces
Walk the onboarding code path end-to-end and enumerate every surface that changes state during provisioning or deprovisioning - tenant registry, identity store, database layer, object storage, quota table, billing record, seed data, re-provisioning path, and teardown. The full surface inventory table, plus the AWS Well-Architected SaaS Lens and Microsoft lifecycle gating notes: references/provisioning-surface-map.md.
Step 2 - Enumerate test scenarios per surface
For each surface from Step 1, identify the scenarios that must hold: account creation atomicity, isolation from the first call, default quota values, billing linkage, seed-data scoping, idempotent re-provisioning, and teardown with full deletion after the retention period. The full per-surface scenario catalog with its Microsoft citations: references/provisioning-test-scenarios.md.
Step 3 - Generate test cases
Naming convention matches the cross-tenant-data-leak-tests planning section:
test_<surface>_<scenario>_<expected>()Examples:
test_account_creation_duplicate_slug_returns_conflict()
test_isolation_new_tenant_invisible_to_existing_tenant_immediately()
test_quota_default_values_match_tier_config_on_creation()
test_billing_record_exists_and_references_tenant_id_on_creation()
test_seed_data_scoped_to_new_tenant_only()
test_provisioning_idempotent_no_duplicate_quota_row()
test_offboarding_application_data_deleted_after_retention_period()Required fixtures
| Fixture | Purpose |
|---|---|
existing_tenant | A fully-provisioned tenant in steady state (pre-exists the new tenant) |
new_tenant | The tenant being provisioned under test |
new_tenant_owner | The user who triggered provisioning |
pricing_tier | The tier config record that defines default quotas and features |
billing_stub | A test double for the payment provider that records linkage calls |
offboarded_tenant | A tenant that has been offboarded; used for teardown assertions |
Step 4 - Pick the test framework and emit skeleton
Same framework selection table as the cross-tenant-data-leak-tests planning section:
| Stack | Framework |
|---|---|
| Python (Django/Flask/FastAPI) | pytest |
| Node (Express/Nest) | Jest or Vitest + Supertest |
| JVM (Spring/Quarkus) | JUnit 5 + Testcontainers |
| Go | testing + httptest |
| Ruby (Rails) | RSpec + request specs |
The full pytest skeleton (one test per Step 2 scenario) and a language-agnostic SQL idempotency assertion: references/test-skeleton-examples.md.
Step 5 - Coverage matrix
Track one cell per (surface, scenario). Empty cells are coverage gaps that the PR author must justify or schedule.
Surface | create | isolation_at_create | quota | billing | seed | idempotent | teardown
account_record | X | | | | | X | X
identity_bindings | X | | | | | X | X
database_isolation | | X | | | | | X
quota_table | | | X | | | X | X
billing_record | | | | X | | X | X
seed_data | | X | | | X | | X
feature_flags | | | X | | X | |Generate this matrix from Step 1's surface inventory plus Step 2's scenario list. Populate it cell-by-cell as test cases are written.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Testing only happy-path provisioning | Misses atomicity on mid-flow failure; offboarding is never exercised | Add failure-injection and teardown cases |
| Asserting isolation only in steady state | Per Microsoft tenancy models guidance, isolation must hold from the first call | Add immediate post-creation cross-tenant probe |
| Skipping idempotency tests | Re-provisioning retries after transient failures; duplicate records cause silent billing or quota bugs | Add second-call and partial-failure-retry cases |
| Hardcoding expected quota values as literals | Quota values change with pricing tiers; tests break on plan changes | Read expected values from the tier config fixture |
| Deleting test tenants without asserting deletion | Teardown path is untested; data retention bugs go undetected | Assert row counts = 0 across all tenant-bearing tables after delete |
| Using a superuser connection for post-offboarding assertions | Superuser bypasses RLS and soft-delete filters; tests pass even when app-layer deletion is broken | Use the app-role or the API layer to verify absence |
| Treating provisioning as atomic when it is not | Cloud provisioning pipelines are often eventually consistent; test may pass before side-effects complete | Use explicit wait/poll or event-driven fixtures that confirm each step before asserting |
Worked example
A Django SaaS adds a Team-tier onboarding flow. Step 1 inventories the surfaces it touches: the tenants registry, identity role bindings, a TenantQuota row, a billing_subscriptions record, and a seeded welcome project. Step 2 selects the scenarios: duplicate slug returns conflict, the new tenant is invisible to an existing tenant immediately, the quota matches the tier default, billing is linked, a second provisioning call stays idempotent, and offboarding deletes the data.
For idempotency, Step 3 names the test test_provisioning_idempotent_no_duplicate_quota_row() and Step 4 emits the pytest body from the skeleton: it POSTs /api/tenants/ a second time with the same slug, then asserts TenantQuota.objects.filter(tenant=new_tenant).count() == 1. Run as the non-superuser app role, the second call reuses the existing record rather than inserting a duplicate, so the assertion passes. The quota_table x idempotent cell in the Step 5 matrix flips to X.
Output
This skill produces:
The runtime isolation gate is cross-tenant-data-leak-tests.
References
Provisioning pipeline surface map
View source (opens in new window)Provisioning pipeline surface map
Walk the onboarding code path end-to-end and enumerate every surface that changes state during provisioning or deprovisioning. Per the AWS Well-Architected SaaS Lens (docs.aws.amazon.com/wellarchitected/latest/saas-lens/tenant-isolation.html (opens in new window)), isolation must be established at every layer of the stack independently:
| Surface | What to capture | How to find it |
|---|---|---|
| Tenant registry / catalog | Record created, unique key assigned | Central tenants table or tenant-management service |
| Identity store | User account created, role bindings | Auth provider admin API or IAM config |
| Database layer | Schema/row/database provisioned per isolation model | ORM migration runner, schema-per-tenant scripts |
| Object storage | Bucket or prefix created with scoped policy | IaC for storage resources |
| Quota / rate-limit table | Default quotas inserted for the new tenant | Quota service or DB seeding script |
| Billing record | Billing entry linked to tenant ID | Billing service or payment-provider webhook handler |
| Seed / default data | Feature flags, default roles, template data loaded | Seed scripts, fixture loaders |
| Re-provisioning path | Idempotent re-run: no duplicate records, no failed constraints | Provisioning entry point called twice |
| Offboarding / teardown | Data deletion, billing cancellation, quota cleanup | Delete or deactivate endpoint |
Per the Microsoft Multitenant Architecture Center (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenant-life-cycle (opens in new window)), onboarding must account for data residency, compliance tier, billing model, and disaster-recovery SLOs, each of which can gate provisioning steps. Capture which conditions gate each surface - a compliance-gated provisioning step needs its own test branch.
Per the Microsoft resource organisation guidance (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/approaches/resource-organization (opens in new window)), quota and subscription limits must be modelled per tenant at provisioning time. Record the expected default quota values - they are assertions, not implementation details.
Provisioning test scenarios per surface
View source (opens in new window)Provisioning test scenarios per surface
For each surface from the surface map, identify the scenarios that must hold.
Account creation
Isolation at creation (no cross-tenant bleed)
Per the Microsoft tenancy models guidance (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenancy-models (opens in new window)), isolation must hold from the first request a tenant makes, not only after steady-state data accumulates. Test cases:
Default resource quotas
Per the Microsoft consumption measurement guidance (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/measure-consumption (opens in new window)), per-tenant consumption limits should be tracked from provisioning. Test cases:
Billing record linkage
Per the Microsoft tenant lifecycle guidance (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenant-life-cycle (opens in new window)), billing linkage is a first-class onboarding concern. Test cases:
Seed and default data
Idempotent re-provisioning
An idempotent provisioning call is one that produces the same final state regardless of how many times it is invoked. Test cases:
Teardown and offboarding
Per the Microsoft tenant lifecycle guidance (learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenant-life-cycle (opens in new window)), offboarding must define a retention period and support re-onboarding during that window. Test cases:
Provisioning test suite skeleton examples
View source (opens in new window)Provisioning test suite skeleton examples
Concrete skeletons for the emitted suite. Pick the framework from the selection table in SKILL.md; the pytest example below maps one-to-one to the scenarios in provisioning-test-scenarios.md (opens in new window).
Example skeleton (pytest)
import pytest
class TestTenantProvisioning:
"""Provisioning lifecycle tests - distinct from runtime isolation battery."""
def test_account_creation_stores_tenant_record(
self, provisioning_client, pricing_tier
):
response = provisioning_client.post(
"/api/tenants/",
data={"slug": "acme", "plan": pricing_tier.id, "owner_email": "owner@acme.com"}
)
assert response.status_code == 201
assert response.json()["id"] is not None
def test_isolation_new_tenant_invisible_to_existing_tenant_immediately(
self, client, existing_tenant_user, new_tenant
):
# New tenant was just provisioned; existing tenant must not see it
client.force_login(existing_tenant_user)
response = client.get("/api/workspaces/")
ids = {w["id"] for w in response.json()["results"]}
assert new_tenant.id not in ids
def test_quota_default_matches_tier_on_creation(
self, new_tenant, pricing_tier
):
from quotas.models import TenantQuota
quota = TenantQuota.objects.get(tenant=new_tenant)
assert quota.max_users == pricing_tier.default_max_users
assert quota.max_storage_gb == pricing_tier.default_max_storage_gb
def test_billing_record_linked_on_creation(
self, new_tenant, billing_stub
):
assert billing_stub.was_called_for(new_tenant.id), (
"Billing provider must be notified during provisioning"
)
record = billing_stub.get_record(new_tenant.id)
assert record["plan"] == new_tenant.plan
def test_seed_data_scoped_to_new_tenant_only(
self, client, existing_tenant_user, new_tenant
):
# Existing tenant must not see new tenant's seed data in shared endpoints
client.force_login(existing_tenant_user)
response = client.get("/api/projects/")
tenant_ids = {p["tenant_id"] for p in response.json()["results"]}
assert str(new_tenant.id) not in tenant_ids
def test_provisioning_idempotent_no_duplicate_quota_row(
self, provisioning_client, new_tenant, pricing_tier
):
# Call provisioning a second time with the same input
provisioning_client.post(
"/api/tenants/",
data={"slug": new_tenant.slug, "plan": pricing_tier.id,
"owner_email": "owner@acme.com"}
)
from quotas.models import TenantQuota
count = TenantQuota.objects.filter(tenant=new_tenant).count()
assert count == 1, "Idempotent re-provisioning must not create duplicate quota rows"
def test_offboarding_deletes_application_data(
self, admin_client, offboarded_tenant
):
admin_client.delete(f"/api/tenants/{offboarded_tenant.id}/")
response = admin_client.get(f"/api/tenants/{offboarded_tenant.id}/projects/")
assert response.status_code == 404Example: idempotency via SQL assertion (language-agnostic)
-- After two provisioning calls for the same tenant slug:
SELECT count(*) FROM tenant_quotas WHERE tenant_id = '<new_tenant_uuid>';
-- Expected: 1 (not 2)
SELECT count(*) FROM billing_subscriptions WHERE tenant_id = '<new_tenant_uuid>';
-- Expected: 1 (not 2)Related skills
cross-tenant-data-leak-tests
Workflow-driven skill that plans and implements the cross-tenant leak-test suite - from surface inventory to the runtime CI gate a multi-tenant codebase must pass on every PR. The planning section inventories tenant-bearing surfaces (tables, APIs, object storage, search, queues, caches), classifies each by isolation model (silo / pool / bridge, per references/isolation-models.md), and derives the OWASP WSTG-ATHZ-02 coverage matrix. The battery defines the canonical test patterns (read-other-tenant-by-id, list-leak, spoofed-tenant-id-in-body, JWT-replay, FK-cross-tenant, unique-collision side channel, object-storage IDOR, search-index-direct-query, async-job-context-reload, cache-key-collision), the 404-vs-403 disclosure trade-off, the Postgres-RLS-direct patterns, and the CI integration (non-superuser non-BYPASSRLS role, fail the build on any leak). Use when designing or implementing a tenant-isolation test suite, adding the CI gate to an existing project, or investigating a leak finding.
rls-reference
Pure-reference catalog of row-level security for tenant isolation, Postgres-first. Covers enabling RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY, default-deny semantics), CREATE POLICY syntax (USING vs WITH CHECK clauses, FOR SELECT/INSERT/UPDATE/DELETE/ALL, permissive vs restrictive, TO role_name), bypassing RLS (superuser / BYPASSRLS / table owner / FORCE ROW LEVEL SECURITY), tenant context patterns (current_user, current_setting, JWT claims via Supabase auth.uid() / auth.jwt()), and performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns). Row/tenant isolation on the non-Postgres engines - MySQL / MariaDB invoker views, CockroachDB native RLS, Vitess vindex sharding, SQL Server security policies - lives in references/other-engines.md. Use as the RLS-pattern reference for tenant isolation on any of these engines. Consumed by cross-tenant-data-leak-tests.