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 tenant-leak-test-author (runtime cross-tenant access) and cross-tenant-data-leak-tests (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 tenant-leak-test-author.
The workflow is:
Differentiation
| Skill | What it tests |
|---|---|
tenant-onboarding-test-author (this skill) | Provisioning lifecycle: creation, quota, billing, seed data, idempotency, teardown |
tenant-leak-test-author | 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 tenant-leak-test-author:
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 tenant-leak-test-author:
| 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 emits the runtime CI gate of cross-tenant leak tests - the actual battery a multi-tenant codebase must pass on every PR. 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 expected response codes per pattern (404 vs 403 disclosure trade-off), the Postgres-RLS-direct test patterns, and the CI integration (run with non-superuser non-BYPASSRLS role, fail the build on any leak). Use when implementing the actual leak-test suite (after tenant-leak-test-author produces the plan), when adding the CI gate to an existing project, or when investigating a leak finding.
multi-engine-row-level-security-reference
Pure-reference catalog of row/tenant isolation mechanisms across four database engines: MySQL and MariaDB (no native RLS - views with SQL SECURITY INVOKER plus app-layer enforcement), CockroachDB (native RLS via ALTER TABLE ENABLE ROW LEVEL SECURITY and CREATE POLICY, matching Postgres semantics), Vitess (keyspace sharding + vindexes route tenant writes to dedicated shards without a policy layer), and SQL Server (CREATE SECURITY POLICY with inline table-valued function filter/block predicates). Covers the isolation mechanism, tenant-context pattern, bypass risks, and test patterns for each engine. Use when designing or auditing tenant isolation on MySQL, MariaDB, CockroachDB, Vitess, or SQL Server.
row-level-security-postgres-reference
Pure-reference catalog of Postgres Row-Level Security (RLS) for tenant isolation. 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()), performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns), and anti-patterns. Use as the RLS-pattern reference for Postgres-backed tenant isolation. Consumed by tenant-leak-test-author, cross-tenant-data-leak-tests.
tenant-isolation-models-reference
Pure-reference catalog of tenant-isolation models for B2B SaaS. Defines the isolation continuum from full-isolation (separate compute + data + network per tenant) to fully-shared (one deployment, tenant_id discriminator), names the canonical models (Microsoft's automated-single-tenant / fully-multitenant / vertically-partitioned / horizontally-partitioned; AWS Well-Architected's silo / pool / bridge framing; deployment-stamps / supertenants terminology), enumerates the trade-offs (cost, blast radius, noisy neighbor, compliance, scale limits), and lists the test surfaces each model creates (cross-tenant data leak, tenant-id propagation, deployment-routing). Use as the model-selection reference when designing or auditing tenant isolation. Consumed by tenant-leak-test-author, cross-tenant-data-leak-tests.
tenant-leak-test-author
Workflow-driven skill that builds a tenant-leak test plan from an inventory of tenant-bearing surfaces (database tables, APIs, object storage, search indices, async messages) and the isolation model in use. Walks through identifying tenant-bearing surfaces, enumerating the attack patterns per OWASP WSTG-ATHZ-02 (horizontal escalation, vertical escalation, IDOR / BOLA), generating test cases that exercise each pattern against each surface, and emitting the test suite skeleton (pytest / Jest / JUnit / Go test) with explicit cross-tenant probes. Use when designing a multi-tenant test suite for a new feature, when auditing test coverage for an existing tenant boundary, or when reviewing PRs that add tenant-bearing surfaces. Distinct from cross-tenant-data-leak-tests which is the runtime gate; this skill produces the plan.