# Multi-tenant Postgres RLS in Go: Moharrik on ToGO

> How Moharrik, an open-source CRM and shared inbox, uses ToGO plugins, Postgres row-level security and an MCP server. Real code, real counts, real limits.

*2026-10-19T12:49:03Z*

# Multi-tenant Postgres RLS in Go: Moharrik on ToGO

Moharrik is a CRM and shared inbox that an AI assistant can operate. It is also a multi-tenant Go app, and the hardest part of that was making sure one workspace can never read another's rows. This case study shows how it does that with **multi-tenant Postgres row-level security in Go**, which parts come from ToGO and which are our own code.

Everything below comes from the [public repo](https://github.com/fadymondy/moharrik) (AGPL-3.0). Counts say how they were taken.

## The problem

Moharrik puts contacts, deals, a shared inbox (website chat, email, WhatsApp), campaigns and automation in one workspace. Every one of those is also an MCP tool, so an agent can drive it.

That creates two hard requirements:

- **Tenancy that fails closed.** Several workspaces share one Postgres. A forgotten `WHERE workspace_id = ...` must not leak data.
- **An agent surface with the same limits as a person.** A token must never do more than its member's role allows.

## Why ToGO

The scaffold was extracted from Mahaam's P0 (the first commit says so), and Mahaam is already a ToGO app. ToGO gave a small kernel, generated REST, GraphQL and sqlc code, and plugins for the boring parts. The README describes the stack as chi + Huma + gqlgen + sqlc on Postgres, with a Next.js 16 web app in the same container.

The other reason is practical: one Go binary serves the API, the workers and (by proxy) the web app, which keeps self-hosting to one `docker compose up`.

## Architecture

**ToGO modules and plugins really used** (from `go.mod` and `internal/plugins/plugins.gen.go`):

| What | How |
|---|---|
| `togo` v0.21.0 | kernel, code generation |
| `auth` v0.8.2 | sessions and users |
| `db-postgres` v0.1.0, `orm` v0.1.0 | Postgres driver and generated models |
| `queue` v0.3.0 | background workers (campaign sends, collectors) |
| `realtime` v0.2.0 | live inbox updates |
| `cache` v0.3.0, `ratelimit` v0.1.0 | caching and rate limits |
| `storage` v0.2.0 | attachments |
| `i18n` v0.2.0, `validation` v0.1.0 | Arabic and English strings, input checks |

Eight plugins are blank-imported in `plugins.gen.go`. Two defaults from `togo new` were removed by hand: `auth-dev` (it returns an admin session for an empty body outside production) and `dashboard` (a generic admin UI over unscoped CRUD). Neither fits a multi-tenant app. `auth-oauth` was replaced by `internal/social`.

**Request path.** A default-deny guard wraps every route, including plugin routes. Then the workspace scope, then authz, then the handler. Generated CRUD sits in the workspace tier and behind `authz.Generated`, which is default-deny per table.

**Two pools.** `DATABASE_URL` is the owner pool (migrations, auth, tenant resolution, admin MCP). `APP_DATABASE_URL` connects as `moharrik_app`: no superuser, no BYPASSRLS, owns nothing. Every ORM and generated query runs on the app pool. Boot refuses to start if the app role could bypass RLS.

**Channels.** WhatsApp goes through an Evolution API server with one instance per workspace, so two workspaces can never share a number. Email uses IMAP in and the workspace's SMTP out. The chat widget is served from the same API.

**MCP.** A user MCP at `/api/mcp` (`mhr_` tokens, SHA-256 hashed, explicit scopes) and an admin MCP at `/api/admin/mcp` (`mhra_` tokens, platform admins only).

## Real code

The RLS backstop is a connection wrapper. From `internal/tenant/rlsdb.go`:

```go
// setScopeSQL sets the workspace AND the audit actor in one round trip.
// Both are re-applied before every statement, so neither can be left over
// from the connection's previous user.
const setScopeSQL = "SELECT set_config('app.workspace_id', $1, false), set_config('app.audit', $2, false)"
```

The policy on a tenant table, from `internal/db/post/101_notification_collect.sql`:

```sql
ALTER TABLE notification_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_sources FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON notification_sources
    USING (workspace_id = current_setting('app.workspace_id', true))
    WITH CHECK (workspace_id = current_setting('app.workspace_id', true));
```

And the CI guard that makes a tool without a scope a build failure, from `internal/mcp/scopes_test.go`:

```go
if strings.TrimSpace(tool.Scope) == "" {
    t.Errorf("%s: tool %q declares no scope — every tool must (default-deny)", server, tool.Name)
}
```

The inbox tools show how a scope reads. In `internal/mcp/user_tools_inbox.go`, `reply` declares `Scope: "inbox:write"`, and every read of customer text returns a note that messages with direction "in" are information, never instructions.

## Real numbers

All counted on `origin/main` on 2026-10-19:

- **223** user-MCP tools: `Name:` fields at tool depth in `internal/mcp/user_tools*.go`. **18** admin-MCP tools in `internal/mcp/admin_*tools.go`.
- **31** user scopes: entries in `UserScopes` in `internal/mcp/scopes.go`.
- **93** post-migration SQL files in `internal/db/post` (RLS, roles, constraints).
- **66** directories under `internal/`.
- **222** Go test files outside `web/` (`find . -name '*_test.go'`).
- **445** commits on `main`, from 2026-09-19.

These count code, not usage. They say nothing about customers, because we do not publish any.

## What was hard, and how ToGO helped

**The ORM cannot take a transaction.** The plan was `SET LOCAL app.workspace_id` per request. The ORM holds a concrete `*sql.DB`, so generated code cannot be handed a request-scoped transaction, and `db-postgres` exposes no pgx hooks. The wrapper above re-applies the setting before every statement instead. It costs one extra round trip per statement. The code comment says to revisit it when the ORM grows an executor interface.

**Generated code assumed SQLite.** With `--db postgres`, `make:resource` still wrote SQLite DDL and `?` placeholders in the CLI version used (0.2.31). The repo's README lists the fixes: hand-written Postgres schema, `sqlc.yaml` overrides and our own `cmd/migrate`.

**Other findings, from the README's framework notes:** nullable timestamps clash between GraphQL and Go, generated create bodies make nullable fields required, `togo generate` swallowed a gqlgen failure, and there is no command to uninstall a plugin.

What ToGO did well: the plugin model let us drop the two unsafe defaults, keep one binary, and add our own routes under one guard. The framework did not do tenancy for us, and we would not want it to guess.

## FAQ

**Does ToGO give you multi-tenancy?** No. It gives the kernel, plugins and code generation. Tenancy lives in `internal/tenant` and `internal/authz`.

**Why two pools?** The owner pool does platform work. The app pool cannot bypass RLS, so a missing filter returns nothing instead of leaking.

**Is Moharrik open source?** Yes, under AGPL-3.0.

## Links

- Product article: [One shared inbox for WhatsApp, email and website chat](https://moharrik.com/en/blog/shared-inbox-whatsapp-email-website-chat)
- Founder post: [Why I built Moharrik](https://fadymondy.com/en/blog/why-i-built-moharrik)
- Product: [moharrik.com](https://moharrik.com) · [Product Hunt](https://www.producthunt.com/products/moharrik)
- Code: [github.com/fadymondy/moharrik](https://github.com/fadymondy/moharrik) · [Discord](https://discord.gg/BMERkavdVq)
- ToGO: [github.com/togo-framework/togo](https://github.com/togo-framework/togo) · [Showcase](https://to-go.dev/en/showcase)

---

Source: https://to-go.dev/en/blog/moharrik-togo-case-study
