Multi-tenancy has three common implementations: a database per tenant, a
schema per tenant, and a column on every row. For a SaaS that starts small and
must stay operable by one person, the column wins — provided the rule is
enforced in one place rather than remembered in fifty.
Here is the whole design, as implemented in GoVueKit, and the reasoning behind
each choice.
Every business table has an organization_id. Every query filters on it.
The value comes from the request context, never from the client.
Three sentences, and they are the entire security model for tenancy. The
implementation exists to make violating them awkward.
Tenant-scoped routes live under /api/orgs/{orgID}/…. That is deliberate: the
tenant is part of the URL, so it is visible in logs, in tests, and in the
router itself. The alternative — an implicit "current organization" in the
session — makes every request depend on hidden state, and makes tab-switching
users a bug report.
// Context loads the organization and the caller's membership. Non-members
// get a 404 — indistinguishable from a nonexistent org, so org IDs never
// leak across tenants. Must be mounted after RequireAuth.
func (o *Org) Context(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := UserFrom(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
orgID := chi.URLParam(r, "orgID")
m, err := o.Q.GetOrgMember(r.Context(), sqlcgen.GetOrgMemberParams{
OrganizationID: orgID, UserID: u.ID,
})
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not found")
return
}
// ... load the organization, put {Org, Role} in the context
})
}
The membership lookup is the authorisation check. If the caller is not a
member, the request dies here — before any handler, before any query, before
any chance to forget.
A 403 says: this organization exists, and you are not in it. That is an
information leak, and a useful one for an attacker enumerating ids. A 404 says
nothing at all.
The same reasoning applies inside a tenant: a resource that belongs to another
organization is not "forbidden", it is not found, because from the caller's
point of view it does not exist.
The exception is roles inside an organization the caller is a member of.
There, 403 is correct: the member knows the organization exists, they can see
the button is disabled, and hiding the difference would only confuse them.
sqlc generates a function per SQL statement. Write the statement with the
tenant in the WHERE clause and the generated signature demands it:
-- name: DeleteOrgInvitation :exec
DELETE FROM org_invitations WHERE id = $1 AND organization_id = $2;
err := q.DeleteOrgInvitation(ctx, sqlcgen.DeleteOrgInvitationParams{
ID: invID, OrganizationID: orgCtx.Org.ID,
})
A developer who wants to bypass tenancy has to write a new SQL statement,
which shows up in review as a file change in internal/db/queries/. That is
the property you want: the unsafe path is visible, not convenient.
This is also why the kit uses sqlc rather than an ORM. With a query builder,
Invitation.find(id) compiles, runs, and returns another tenant's row. There
is no equivalent of that mistake here, because there is no generic finder.
one.With(middleware.RequireOrgRole(org.RoleOwner)).Delete("/", orgsH.Delete)
The frontend also hides the delete button, and that check is explicitly
cosmetic. Anyone can open the console and call the endpoint; the middleware is
what answers.
One rule that is easy to miss: the last owner of an organization cannot be
demoted, removed, or leave. Without it, organizations become unadministrable
and support tickets become database surgery.
Every one of the above can be undone by a well-meaning refactor. So the
property gets a test at the level where it actually matters — through HTTP,
against a real database:
1. Create user A and organization A.
2. Create user B and organization B.
3. As B, GET /api/orgs/{orgA} → 404
4. As B, GET /api/orgs/{orgA}/members → 404
5. As a member (not owner) of A,
PATCH /api/orgs/{orgA}/members/{userID} → 403
Steps 3 and 4 are the ones that matter. They are cheap to write once and they
fail loudly the day someone adds a query without the tenant.
In GoVueKit this exists twice: as integration tests run against both database
engines in CI, and as a Playwright journey against the production binary.
Write it at the same time as the resource, never afterwards — a test you must
remember to add later is a test that gets added after the incident.
Be clear about the limits before you adopt it:
In exchange you get one database to operate, migrations that run once, and the
ability to answer "how many organizations are on the paid plan?" with a single
GROUP BY. For the overwhelming majority of SaaS products, that trade is
correct — and if you outgrow it, you outgrow it with revenue.
organization_id on every business table, with a cascade and an index.
See it in the code excerpts on the landing page, or read how the
same discipline applies to billing webhooks.
Originally published on govuekit.dev. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one docker compose up: https://govuekit.dev/labs