# Multi-Tenant SaaS in Go with Postgres RLS: How Mahaam Is Built on ToGO

> A case study of Mahaam, an open-source project management system built on ToGO: tenancy with Postgres row-level security, a 119-tool MCP server, real numbers and the framework limits we hit.

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

# Multi-Tenant SaaS in Go with Postgres RLS: How Mahaam Is Built on ToGO

[Mahaam](https://mahaam.app) is an open-source project management system for agencies: projects, issues, time tracking, invoices, a customer portal and an MCP server so AI agents can work the board. It is a multi-tenant SaaS written in Go on [ToGO](https://github.com/togo-framework/togo), with Postgres row-level security (RLS) as the tenancy backstop. This is how it is put together, with the numbers I can count from the repo and the places where ToGO got in the way.

**The short version:** ToGO gave us the kernel, the plugin system, REST, GraphQL and the MCP-ready layout. Multi-tenancy is not something ToGO does for you, so Mahaam adds it with two Postgres roles, one policy per tenant table and a connection wrapper that sets the workspace on every statement.

## 1. The problem

For years the studio's projects, timesheets and invoices lived in three tools that never agreed. The goal was one dataset: track hours against a project budget and draft the invoice from the same entries, so nothing is retyped. Three constraints shaped the build:

- **Many workspaces, one database.** Every customer is a workspace, and one bug must never leak rows across workspaces.
- **Humans and agents on the same rules.** An AI agent starting a timer should hit the same authorization path as the browser.
- **Arabic and English**, right-to-left included.

## 2. Why ToGO

I wanted a Go backend where the boring parts already exist: a kernel, configuration, a plugin registry, generated REST and GraphQL, a queue, a scheduler and mail. ToGO ships those as plugins you blank-import, so the app is mostly its own domain code. Mahaam is one of the products I build on it; the framework is open source at [github.com/togo-framework/togo](https://github.com/togo-framework/togo).

## 3. Architecture

**ToGO modules.** `go.mod` pins `github.com/togo-framework/togo v0.21.0` plus 15 plugin modules: `auth`, `auth-oauth`, `cache`, `db-postgres`, `faker`, `i18n`, `import-export`, `mail`, `orm`, `queue`, `ratelimit`, `realtime`, `scheduler`, `storage` and `validation`. Twelve of them are blank-imported in `internal/plugins/plugins.gen.go` (`auth`, `auth-oauth`, `cache`, `db-postgres`, `i18n`, `import-export`, `mail`, `queue`, `ratelimit`, `realtime`, `scheduler`, `storage`).

**Around ToGO.** HTTP is chi with Huma (REST + OpenAPI). GraphQL is gqlgen. SQL is sqlc over Postgres. The web app is Next.js 16 with an Arabic/English layout. Clients in `apps/` include an Electron tray app, a Chrome extension and Expo mobile apps.

**One domain layer.** `internal/work` holds the business rules. REST handlers under `/api/w/*` and the MCP tools call the same functions, so a browser and an agent share one authorization path.

**Two Postgres pools.**

- The **app pool** connects as a role that owns nothing, is not a superuser and has no `BYPASSRLS`. Every request-scoped query goes through it.
- The **owner pool** is used only for tenant resolution, sign-in, token lookup and the admin MCP.

**Two MCP servers.** The user MCP lives at `/api/mcp` and the admin MCP at `/api/admin/mcp`. Every tool declares a scope, and a CI test fails if one does not.

## 4. Real code

**The tenancy fence is a policy per table.** From `internal/db/post/20_rls.sql`:

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

No workspace in context means the setting is empty, so every policy is false. Default deny.

**The workspace is set on every statement.** The ORM in ToGO holds a concrete `*sql.DB`, so a per-request `SET LOCAL` inside a transaction was not possible for generated code. Mahaam wraps the driver instead (`internal/tenant/rlsdb.go`):

```go
const setScopeSQL = "SELECT set_config('app.workspace_id', $1, false)"
```

Each connection runs that before every statement, on the same physical connection and with the context of the statement. Cost: one extra round trip per statement. The file's own comment says to revisit it when the ORM gets an executor interface.

**A CI guard keeps queries honest.** `internal/guards/sqlc_guard_test.go` fails any sqlc query on a tenant table that does not constrain `workspace_id`, so RLS is the backstop and not the only fence. A query that is cross-tenant on purpose must say so with a `-- guard:cross-tenant <reason>` comment that a reviewer sees.

**An MCP tool is a struct with a scope.** From `internal/mcp/time_tools.go`:

```go
{
    Name:        "get_running_timer",
    Scope:       "time:read",
    InputSchema: schema(nil, map[string]any{"user": prop("string", "Email, user id or \"me\" (someone else needs time:manage)")}),
    Run: func(ctx context.Context, a *app.App, raw json.RawMessage) (string, error) {
        // ...calls work.New(a).RunningTimers(...)
    },
},
```

## 5. Real numbers

All counted from the `master` branch of [fadymondy/mahaam](https://github.com/fadymondy/mahaam) on 2026-10-19.

| What | Count | How it was counted |
|---|---|---|
| ToGO plugin modules in `go.mod` | 15 (plus the core at v0.21.0) | direct `togo-framework/` requires, excluding `// indirect` |
| Plugins blank-imported | 12 | `internal/plugins/plugins.gen.go` |
| User MCP tools | 119 | distinct `Name:` entries in `internal/mcp/*_tools.go`, excluding `admin_*` |
| Admin MCP tools | 24 | `Scope:` entries in `internal/mcp/admin_*.go` |
| User MCP scopes | 34 | entries in `UserScopes`, `internal/mcp/scopes.go` |
| Schema files | 57 | `internal/db/schema/*.sql` |
| Constraint, RLS and data-fix SQL files | 45 | `internal/db/post/*.sql` |
| sqlc query files | 28 | `internal/db/queries/*.sql` |
| RLS policies | 21 | `CREATE POLICY` in `internal/db/post/*.sql` |
| Go test files / test functions | 89 / 167 | `*_test.go` and `func Test` under `internal` and `cmd` |
| Commits since the first one (2026-09-14) | 254 | `git log master` |

I am not quoting users, revenue or performance numbers because I have none to source.

## 6. What was hard, and how ToGO helped

**Helped:** the plugin model. Mail, queue, scheduler, rate limiting, storage and i18n arrived as dependencies, so the repo is mostly Mahaam's domain code. The kernel, REST and GraphQL scaffolding got the first schema running quickly.

**Hard, honestly:** the README keeps a numbered list of framework notes from building P0 to P5 (CLI 0.2.31, ToGO v0.21.0). The ones that cost the most time:

- **No tenancy hook.** Because the ORM holds a concrete `*sql.DB`, generated handlers cannot run inside a per-request transaction. The connection wrapper above is the workaround.
- **Scaffold defaults that don't fit a multi-tenant app.** `togo new` installs `auth-dev`, which returns an admin session for an empty POST in any non-production environment, and a generic dashboard over unscoped CRUD. There is no uninstall command, so `plugins.gen.go` was edited by hand.
- **Plugins that mount unauthenticated routes.** `import-export` mounts `/api/export/{resource}` and `/api/import/{resource}`, and `settings` (pulled in through `queue`) mounts `/api/settings/{scope}/{key}`, all without auth or tenancy. A guard in Mahaam blocks them.
- **Realtime is not per-user.** The SSE broker broadcasts to everyone, so per-user data cannot ride on it as shipped.
- **Codegen can fail quietly.** `togo generate` printed "Codegen complete" and exited 0 when gqlgen or the OpenAPI gate failed, so we grep its output.
- **`ratelimit` middleware** answers `text/plain`, which an MCP client cannot parse. Mahaam calls `Allow` and writes the JSON-RPC 429 itself.

Some of these may have changed in newer ToGO releases; the list is as found on v0.21.0.

## 7. FAQ

**Does ToGO give you multi-tenancy?** No. Mahaam implements it: two Postgres roles, `FORCE ROW LEVEL SECURITY` policies, a per-statement `set_config` wrapper and a CI guard on queries.

**Why not `SET LOCAL` in a transaction?** The ORM takes a concrete `*sql.DB`, so generated code has no way to receive a request-scoped transaction. The wrapper costs one extra round trip per statement.

**Is Mahaam open source?** Yes. The repository is public on GitHub under AGPL-3.0.

**How do agents connect?** Through the MCP endpoint at `/api/mcp` with a scoped token minted in the app. The agent's effective scopes are narrowed to its human's workspace role.

## 8. Links

- Product article: [A project management MCP server for AI agents](https://mahaam.app/en/blog/project-management-mcp-server-for-ai-agents)
- Founder post: [Why I built Mahaam](https://fadymondy.com/en/blog/why-i-built-mahaam)
- Mahaam: [mahaam.app](https://mahaam.app) · [GitHub](https://github.com/fadymondy/mahaam) · [Product Hunt](https://www.producthunt.com/products/mahaam) · [Discord](https://discord.gg/a59RzxnB2H)
- 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/mahaam-togo-case-study
