{"slug": "row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves", "title": "Row-level multi-tenancy in Go: the rule, the middleware and the test that proves it", "summary": "A developer has published the design for row-level multi-tenancy in GoVueKit, a Go SaaS kit, in which every business table carries an organization_id and every query filters on it using a value pulled from the request context rather than the client. Tenant-scoped routes are mounted under /api/orgs/{orgID}/…, where a membership lookup middleware acts as the authorization check and returns 404 rather than 403 for non-members so organization IDs cannot be enumerated. The kit uses sqlc instead of an ORM so that bypassing tenancy requires writing a new SQL statement that surfaces as a visible file change in review.", "body_md": "Multi-tenancy has three common implementations: a database per tenant, a\n\nschema per tenant, and a column on every row. For a SaaS that starts small and\n\nmust stay operable by one person, the column wins — provided the rule is\n\nenforced in one place rather than remembered in fifty.\n\nHere is the whole design, as implemented in GoVueKit, and the reasoning behind\n\neach choice.\n\nEvery business table has an `organization_id`. Every query filters on it.\n\nThe value comes from the request context, never from the client.\n\nThree sentences, and they are the entire security model for tenancy. The\n\nimplementation exists to make violating them awkward.\n\nTenant-scoped routes live under `/api/orgs/{orgID}/…`. That is deliberate: the\n\ntenant is part of the URL, so it is visible in logs, in tests, and in the\n\nrouter itself. The alternative — an implicit \"current organization\" in the\n\nsession — makes every request depend on hidden state, and makes tab-switching\n\nusers a bug report.\n\n```\n// Context loads the organization and the caller's membership. Non-members\n// get a 404 — indistinguishable from a nonexistent org, so org IDs never\n// leak across tenants. Must be mounted after RequireAuth.\nfunc (o *Org) Context(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        u, ok := UserFrom(r.Context())\n        if !ok {\n            writeError(w, http.StatusUnauthorized, \"authentication required\")\n            return\n        }\n        orgID := chi.URLParam(r, \"orgID\")\n        m, err := o.Q.GetOrgMember(r.Context(), sqlcgen.GetOrgMemberParams{\n            OrganizationID: orgID, UserID: u.ID,\n        })\n        if errors.Is(err, sql.ErrNoRows) {\n            writeError(w, http.StatusNotFound, \"not found\")\n            return\n        }\n        // ... load the organization, put {Org, Role} in the context\n    })\n}\n```\n\nThe membership lookup *is* the authorisation check. If the caller is not a\n\nmember, the request dies here — before any handler, before any query, before\n\nany chance to forget.\n\nA 403 says: this organization exists, and you are not in it. That is an\n\ninformation leak, and a useful one for an attacker enumerating ids. A 404 says\n\nnothing at all.\n\nThe same reasoning applies inside a tenant: a resource that belongs to another\n\norganization is not \"forbidden\", it is *not found*, because from the caller's\n\npoint of view it does not exist.\n\nThe exception is roles inside an organization the caller *is* a member of.\n\nThere, 403 is correct: the member knows the organization exists, they can see\n\nthe button is disabled, and hiding the difference would only confuse them.\n\nsqlc generates a function per SQL statement. Write the statement with the\n\ntenant in the `WHERE` clause and the generated signature demands it:\n\n```\n-- name: DeleteOrgInvitation :exec\nDELETE FROM org_invitations WHERE id = $1 AND organization_id = $2;\nerr := q.DeleteOrgInvitation(ctx, sqlcgen.DeleteOrgInvitationParams{\n    ID: invID, OrganizationID: orgCtx.Org.ID,\n})\n```\n\nA developer who wants to bypass tenancy has to write a new SQL statement,\n\nwhich shows up in review as a file change in `internal/db/queries/`. That is\n\nthe property you want: the unsafe path is visible, not convenient.\n\nThis is also why the kit uses sqlc rather than an ORM. With a query builder,\n\n`Invitation.find(id)` compiles, runs, and returns another tenant's row. There\n\nis no equivalent of that mistake here, because there is no generic finder.\n\n```\none.With(middleware.RequireOrgRole(org.RoleOwner)).Delete(\"/\", orgsH.Delete)\n```\n\nThe frontend also hides the delete button, and that check is explicitly\n\ncosmetic. Anyone can open the console and call the endpoint; the middleware is\n\nwhat answers.\n\nOne rule that is easy to miss: the **last owner** of an organization cannot be\n\ndemoted, removed, or leave. Without it, organizations become unadministrable\n\nand support tickets become database surgery.\n\nEvery one of the above can be undone by a well-meaning refactor. So the\n\nproperty gets a test at the level where it actually matters — through HTTP,\n\nagainst a real database:\n\n```\n1. Create user A and organization A.\n2. Create user B and organization B.\n3. As B,   GET   /api/orgs/{orgA}                       → 404\n4. As B,   GET   /api/orgs/{orgA}/members               → 404\n5. As a member (not owner) of A,\n           PATCH /api/orgs/{orgA}/members/{userID}      → 403\n```\n\nSteps 3 and 4 are the ones that matter. They are cheap to write once and they\n\nfail loudly the day someone adds a query without the tenant.\n\nIn GoVueKit this exists twice: as integration tests run against both database\n\nengines in CI, and as a Playwright journey against the production binary.\n\nWrite it at the same time as the resource, never afterwards — a test you must\n\nremember to add later is a test that gets added after the incident.\n\nBe clear about the limits before you adopt it:\n\nIn exchange you get one database to operate, migrations that run once, and the\n\nability to answer \"how many organizations are on the paid plan?\" with a single\n\n`GROUP BY`. For the overwhelming majority of SaaS products, that trade is\n\ncorrect — and if you outgrow it, you outgrow it with revenue.\n\n`organization_id` on every business table, with a cascade and an index.\nSee it in the [code excerpts on the landing page](https://govuekit.dev/#code), or read how the\n\nsame discipline applies to [billing webhooks](https://govuekit.dev/blog/idempotent-billing-webhooks).\n\n*Originally published on [govuekit.dev](https://govuekit.dev/blog/row-level-multi-tenancy-go). 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](https://govuekit.dev/labs)*", "url": "https://wpnews.pro/news/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves", "canonical_source": "https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb", "published_at": "2026-10-01 07:07:03+00:00", "updated_at": "2026-10-01 07:14:11.604238+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["GoVueKit", "Go", "sqlc", "chi"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves", "markdown": "https://wpnews.pro/news/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves.md", "text": "https://wpnews.pro/news/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves.txt", "jsonld": "https://wpnews.pro/news/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves.jsonld"}}