Skip to content

Build1 publisher3 min readPublished

GoVueKit runs one typed membership lookup before any handler sees a tenant request

The Go boilerplate resolves membership once per request in chi middleware and hands the handler an organization row and a role. Every tenant-scoped SELECT still has to name organization_id by hand.

The Engineer · Build desk

Illustration accompanying GoVueKit runs one typed membership lookup before any handler sees a tenant request

What happened

  • Tenancy in the GoVueKit boilerplate sits in three tables: organizations, org_members holding organization, user and role, and every business table, which carries an organization_id column.
  • One sqlc query selecting a membership row by organization and user becomes a typed Go method, and chi middleware calls it once per request before any handler runs.
  • A caller with no membership row gets 404, the same status and body as a wrong organization id, so ids cannot be probed from outside for existence.
  • A second middleware compares the caller's role against a minimum and returns 403 with "insufficient permissions" only after membership has already been settled.
  • The route group that defines {orgID} is the same one that mounts the context middleware, so a tenant-scoped endpoint cannot be registered outside it by accident.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Enforcement of the tenant filter lands on code review, so the boundary degrades with reviewer attention.
  • contradiction The post's stated error rule and its shipped organization lookup do not agree, so an operator who trusts the prose will not expect permission-shaped 404s in an outage.
  • cost Every tenant-scoped endpoint pays the membership preamble, so the cheapest read in the API carries the same authorisation cost as the most expensive write.
  • decision A team that needs per-permission grants or sequence-generated keys is choosing to extend or fork this layer, because neither is on offer as shipped.

The middleware hands each handler an organization row and the caller's role, both read from the database on that request [9]. Downstream code never works out again who the caller is or what they may do; it calls OrgFrom(ctx) [9]. Membership resolution is one typed method, generated by sqlc from one query [5].

Filtering is a different problem, and the middleware leaves it alone. The list query for a tenant's projects is written by hand as `SELECT * FROM projects WHERE organization_id = $1 ORDER BY created_at DESC`, with GetProject adding `AND id = $2` [16]. Those predicates are typed by whoever adds the resource. The post describes the failure mode as a new query that leaves out the organization_id predicate, after which one customer reads another's data [1]. Context carries the authenticated org id, and the next SELECT uses it only if someone typed the predicate. What holds the query layer is review plus the convention that every business table carries organization_id [2], and the post does not describe database-enforced row-level security [23].

The first lookup follows the post's own error rule, which it states as "A database failure is a 500 with a log line, never a 404 that would make an outage look like a permission problem" [11]. A missing membership row returns 404; any other error is logged and returns 500 [7]. The second lookup behaves differently. GetOrganization returns 404 for any error at all, connection failures included, with no log line [8]. So during a database incident, a member of a real organization is told the organization does not exist.

Two queries therefore run before any handler under the tenant group: the membership row, then the organization row [19]. A handler that reads one project makes three queries in total [24]. Both preamble lookups are single-row reads, one on the composite key and one on the id [5][8].

Identifiers are TEXT UUIDs generated in Go, so the schema is identical on PostgreSQL and SQLite and nothing depends on a sequence [3]. Taking the layer as shipped means giving up sequence-generated keys. Roles are three strings with an order, owner > admin > member, and the post declines to ship a permission matrix on the grounds that a boilerplate that ships one ships a maintenance burden; a product needing one adds it next to RoleAtLeast [4].

The role requirement sits on the route line next to the verb, where a reviewer sees it [18]. Of the five role-gated routes shown in the group, three require owner and two require admin [20]. The post's summary of the whole arrangement: "There is one place to get tenancy wrong, and it is thirty lines long" [17].

What to watch

  • Whether a later GoVueKit release logs and returns 500 on GetOrganization errors instead of folding them into the 404 branch.
  • Whether the boilerplate adds a generated test or lint that fails any query on a business table without an organization_id predicate.
  • Whether the two-query middleware preamble gets a per-request cache once someone measures it against a latency budget.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories