← All posts

Designing Multi-Tenant Go Services Without Data Leaks

Analytics dashboard with multiple charts

Multi-tenancy is easy to underestimate because the first version is usually just a `company_id` column. Add the column, add an index, filter by it in queries, and move on. That works until the product grows, background jobs are added, exports are introduced, and one missing filter becomes a serious data leak.

For a platform that manages advertiser data, tenant isolation is not a nice-to-have. It is a core security boundary. The safest design is the one where the boring default path is also the secure path.

Make Tenant Context Explicit

I avoid passing raw IDs through twenty function calls. Instead, request-scoped tenant context becomes a first-class value. It contains the company, profile, marketplace, permissions, and any constraints needed by the downstream service.

type TenantContext struct {
    CompanyID int64
    ProfileID int64
    Country   string
    Roles     []string
}

func TenantFromRequest(r *http.Request) (TenantContext, error) {
    claims := auth.ClaimsFromContext(r.Context())
    if claims.CompanyID == 0 {
        return TenantContext{}, errors.New("missing tenant")
    }
    return TenantContext{
        CompanyID: claims.CompanyID,
        ProfileID: claims.ProfileID,
        Country:   claims.Country,
        Roles:     claims.Roles,
    }, nil
}

Push Filters Into Repositories

The most common mistake is letting handlers build SQL conditions themselves. Some handlers remember the tenant filter, others forget it. A better pattern is to put tenant-aware methods in repositories and make unsafe methods hard to call.

func (r *CampaignRepository) ListActive(ctx context.Context, tenant TenantContext) ([]Campaign, error) {
    const q = `
        SELECT id, name, status, budget
        FROM campaigns
        WHERE company_id = ? AND profile_id = ? AND status = 'ENABLED'
        ORDER BY updated_at DESC
    `
    return scanCampaigns(r.db.QueryContext(ctx, q, tenant.CompanyID, tenant.ProfileID))
}

This does not remove the need for review, but it changes the default. Engineers call tenant-scoped methods because those are the methods that exist.

Background Jobs Are the Dangerous Part

HTTP requests usually have auth middleware. Workers do not. That makes workers the place where tenant isolation silently disappears. Every SQS message, cron job, and backfill task should carry tenant metadata explicitly.

  • Queue messages should include tenant identifiers, not only resource IDs.
  • Workers should reconstruct `TenantContext` before touching storage.
  • Backfills should process one tenant at a time when possible.
  • Admin scripts should log the tenant scope they operate on before doing work.

Test Isolation As A Contract

Unit tests for repositories should seed data for two tenants and assert that only one tenant is returned. It sounds repetitive, but this is exactly the kind of boring test that prevents expensive mistakes.

func TestListActiveDoesNotCrossTenant(t *testing.T) {
    repo := newTestCampaignRepo(t)
    repo.seed(companyA, profileA, "campaign-a")
    repo.seed(companyB, profileB, "campaign-b")

    got, err := repo.ListActive(context.Background(), TenantContext{
        CompanyID: companyA,
        ProfileID: profileA,
    })
    require.NoError(t, err)
    require.Equal(t, []string{"campaign-a"}, names(got))
}

The goal is not paranoia. The goal is to make tenant isolation part of the service contract, visible in code, tests, logs, and operational tooling.

Comments