Multi-Tenancy
ShipQ has first-class support for multi-tenancy through its scope system. When configured, scope injection is woven into the entire compiler chain — migrations, queries, handlers, and tests all enforce tenant isolation automatically.
Configuring Scope
Section titled “Configuring Scope”Multi-tenancy is activated by setting the scope key in the [db] section of shipq.ini:
[db]database_url = postgres://localhost:5432/myapp_devscope = organization_idThe value (organization_id) is the name of the foreign key column that will be injected into every new table to reference the organizations table.
Prerequisites
Section titled “Prerequisites”Before configuring scope, you must have the auth system in place, because the organizations table is created by shipq auth:
shipq initshipq db setupshipq authgo mod tidyshipq migrate upThen add the scope to shipq.ini:
[db]database_url = postgres://localhost:5432/myapp_devscope = organization_idHow Scope Affects the Compiler Chain
Section titled “How Scope Affects the Compiler Chain”Once scope = organization_id is set, every stage of ShipQ’s compiler chain becomes scope-aware.
1. Migrations — Automatic Column Injection
Section titled “1. Migrations — Automatic Column Injection”When you create a new migration:
shipq migrate new pets name:string species:string age:intShipQ automatically injects organization_id:references:organizations into the column list. The resulting migration includes an organization_id foreign key column without you having to specify it.
This means the generated migration is equivalent to:
shipq migrate new pets name:string species:string age:int organization_id:references:organizationsGlobal Tables
Section titled “Global Tables”Some tables shouldn’t be scoped (e.g., lookup tables, shared configuration). Use the --global flag to skip scope injection:
shipq migrate new countries name:string code:string --globalGlobal tables don’t get the organization_id column and aren’t subject to tenant filtering.
2. Queries — Automatic Filtering
Section titled “2. Queries — Automatic Filtering”Generated query definitions automatically include organization_id in their WHERE clauses. When you run shipq resource pets all, the generated queries filter by the tenant’s organization:
- List queries only return rows matching the current user’s
organization_id - Get-one queries include
organization_idin the lookup condition - Create queries set
organization_idfrom the authenticated user’s context - Update queries scope the WHERE clause to the correct tenant
- Delete queries scope the WHERE clause to the correct tenant
This means a user in Organization A can never accidentally read, update, or delete data belonging to Organization B — the filter is baked into the SQL at compile time.
3. Handlers — Context Extraction
Section titled “3. Handlers — Context Extraction”Generated handlers automatically extract the organization_id from the authenticated user’s session context and pass it to query parameters. The flow is:
- Auth middleware reads and verifies the signed
sessioncookie and loads the user’s account - The account includes the user’s
organization_id - The handler extracts
organization_idfrom the context - The handler passes it as a query parameter
- The generated query filters by
organization_id
No manual plumbing is required — the scope value flows from authentication through to the database query automatically.
4. Tests — Tenancy Isolation Verification
Section titled “4. Tests — Tenancy Isolation Verification”This is where ShipQ’s scope system really shines. When scope is configured, shipq handler compile generates tenancy isolation tests alongside your CRUD tests.
These tests verify that:
- A user in Organization A cannot read Organization B’s data
- A user in Organization A cannot update Organization B’s data
- A user in Organization A cannot delete Organization B’s data
- List endpoints only return data belonging to the authenticated user’s organization
The generated tenancy test file is located at:
api/<table>/spec/zz_generated_tenancy_test.goThis file is regenerated on every shipq handler compile, so you don’t need to maintain these tests by hand.
Full Walkthrough
Section titled “Full Walkthrough”Here’s the complete flow for building a scoped, auth-protected resource:
# 1. Initialize the projectshipq initshipq db setup
# 2. Generate auth (creates organizations, accounts, sessions)shipq authgo mod tidy
# 3. Configure scope in shipq.ini# Add under [db]:# scope = organization_id
# 4. Create a scoped migration (organization_id is auto-injected)shipq migrate new pets name:string species:string age:intshipq migrate up
# 5. Generate scoped, auth-protected resourceshipq resource pets allgo mod tidy
# 6. Run all tests (includes tenancy isolation tests)go test ./... -v -count=1Verifying Tenancy Isolation
Section titled “Verifying Tenancy Isolation”After running the steps above, your test suite includes tenancy isolation tests. You can run them specifically:
go test ./api/pets/spec/... -v -count=1Look for test output that shows:
- Creating data as User A (Organization A)
- Attempting to access that data as User B (Organization B)
- Verifying that User B gets a 404 or empty list (not Organization A’s data)
Mixing Scoped and Global Resources
Section titled “Mixing Scoped and Global Resources”In a real application, you’ll likely have both scoped and global tables:
# Scoped tables (organization_id auto-injected)shipq migrate new projects name:string description:textshipq migrate new tasks title:string status:string project_id:references:projects
# Global tables (no organization_id)shipq migrate new roles name:string --globalshipq migrate new permissions action:string resource:string --globalThe --global flag is the escape hatch — use it for any table that should be shared across all organizations.
How It Works Under the Hood
Section titled “How It Works Under the Hood”ShipQ’s scope system is implemented at the compiler level, not at runtime:
-
shipq migrate newreads[db] scopefromshipq.iniand injects the scope column into the migration’s column list before generating the Go migration file. -
shipq resource(and the generated querydefs) includes the scope column in all WHERE clauses, INSERT column lists, and parameter types. -
shipq handler compilegenerates tenancy isolation tests when it detects that a table has the scope column and auth is configured.
Because the filtering happens at the SQL level (not in application middleware), there’s no way to accidentally bypass it. The generated SQL literally includes WHERE organization_id = ? — it’s impossible to query without providing the scope value.
Best Practices
Section titled “Best Practices”- Set scope early — ideally right after
shipq authand before creating any business tables. - Use
--globalintentionally — only for truly shared data like lookup tables, roles, and system configuration. - Trust the generated tests — the tenancy isolation tests are comprehensive. If they pass, your data isolation is correct.
- Don’t remove
organization_idfrom generated queries — the scope column in queries is there by design. Removing it breaks tenant isolation. - Run the full test suite after any schema or handler changes:
go test ./... -v
Next Steps
Section titled “Next Steps”- Authentication — The auth system that powers scope extraction
- Handlers & Resources — How generated handlers use scope
- E2E Example — A full walkthrough that includes multi-tenancy