Multi-Tenant AI Service Design: Isolation and Security, Starting with SQLite

🌐 한국어

The moment you open a single-user AI agent up to multiple customers, the code doesn't get harder—but the cost of failure changes completely. One bug stops meaning "doesn't work" and starts meaning "customer A saw customer B's data."

This article is about building that boundary. There's nothing clever in it. Instead it walks, in order, through the places where omission guarantees an incident.

⚠️ Every key, token, and path in the code below is an example placeholder. Do not use these values as-is.

1. Tenancy Flows Through Context — and Fails Closed

Ninety percent of multi-tenancy is "does every query carry WHERE tenant_id = ? without exception?" The problem is that humans forget.

So rather than passing tenant ID as a function argument, carry it in the request context. Plant it once at the entry point and force the storage layer to pull it back out.

type ctxKey struct{}

func WithTenant(ctx context.Context, id uuid.UUID) context.Context {

    return context.WithValue(ctx, ctxKey{}, id)

}

// The key part: absence is an error, not "return everything" (fail-closed)

func TenantFrom(ctx context.Context) (uuid.UUID, error) {

    id, ok := ctx.Value(ctxKey{}).(uuid.UUID)

    if !ok || id == uuid.Nil {

        return uuid.Nil, errors.New("no tenant in context")

    }

    return id, nil

}

Fail-closed is the whole design. If a missing tenant means "return everything unfiltered," then the day one middleware line goes missing, every customer's data quietly walks out the door. Crashing with an error is overwhelmingly better.

func (s *Store) ListAgents(ctx context.Context) ([]Agent, error) {

    tid, err := TenantFrom(ctx)

    if err != nil {

        return nil, err          // no tenant → stop here

    }

    return s.query(ctx,

        `SELECT * FROM agents WHERE tenant_id = ?`, tid)

}

Where does the tenant come from? It depends on the entry point. A tenant-bound API key is the cleanest — the key alone determines the tenant, so the client never sends a tenant header, and therefore has no way to forge one. Chat channels (Telegram, Discord, and so on) bake the tenant into the channel instance's configuration.

2. Can You Do Multi-Tenancy on SQLite?

Yes. You just need to pick your approach knowingly.

  • Single database + tenant_id column — simple operationally, one migration to run. In exchange, isolation rests entirely on your application code
  • One database file per tenant — isolation is at the file level, which is airtight, and backup and deletion are trivial. In exchange you run migrations N times and cross-tenant analytics get awkward

At tens to hundreds of tenants, a single database is plenty. SQLite serializes writes, so turn on WAL mode so readers and writers don't block each other.

PRAGMA journal_mode = WAL;      -- reader/writer concurrency

PRAGMA busy_timeout = 5000;     -- wait up to 5s on locks

PRAGMA foreign_keys = ON;

-- Composite indexes must lead with tenant_id

CREATE INDEX idx_sessions_tenant_agent

    ON sessions(tenant_id, agent_id, updated_at DESC);

Leading indexes with tenant_id isn't only about performance. It clusters each tenant's rows physically, so a lookup is less likely to touch another tenant's pages at all.

3. Workspace Isolation — Block Path Traversal

Give an agent file tools and the filesystem becomes a new attack surface. Splitting the root per tenant is the easy part.

/data/workspaces/{tenant_id}/{agent_id}/

The real problem is ../../../etc/passwd. An LLM might construct that path, or a user might coax it into doing so. Force every path resolution through one function.

func (fs *ScopedFS) Resolve(rel string) (string, error) {

    // 1) resolve to an absolute path, following symlinks

    abs, err := filepath.Abs(filepath.Join(fs.root, rel))

    if err != nil {

        return "", err

    }

    real, err := filepath.EvalSymlinks(abs)

    if err != nil && !os.IsNotExist(err) {

        return "", err

    }

    if real == "" {

        real = abs      // file doesn't exist yet (about to be created)

    }

    // 2) confirm it lives inside the root — prefix comparison is not enough

    rp, err := filepath.Rel(fs.root, real)

    if err != nil || rp == ".." || strings.HasPrefix(rp, ".."+string(os.PathSeparator)) {

        return "", errors.New("refused: path outside workspace")

    }

    return real, nil

}

Two details carry the weight. Resolve symlinks first — a link inside the workspace pointing outward sails through a naive string check. And use filepath.Rel rather than string prefix matching: /data/ws/tenant-1 and /data/ws/tenant-10 both pass a prefix test.

4. Encrypting API Keys — AES-256-GCM

If tenants register their own LLM provider keys, those keys must not sit in the database as plaintext. AES-256-GCM is the standard choice. GCM performs encryption and integrity verification together, so if anyone tampers with the bytes in the database, decryption fails.

const prefix = "aes-gcm:"

func Encrypt(plaintext, key string) (string, error) {

    if key == "" || plaintext == "" {

        return plaintext, nil

    }

    keyBytes, err := DeriveKey(key)      // normalize to 32 bytes

    if err != nil {

        return "", err

    }

    block, err := aes.NewCipher(keyBytes)

    if err != nil {

        return "", err

    }

    gcm, err := cipher.NewGCM(block)

    if err != nil {

        return "", err

    }

    nonce := make([]byte, gcm.NonceSize())

    if _, err := rand.Read(nonce); err != nil {   // crypto/rand, always

        return "", err

    }

    // Seal appends after the nonce: nonce + ciphertext + auth tag

    out := gcm.Seal(nonce, nonce, []byte(plaintext), nil)

    return prefix + base64.StdEncoding.EncodeToString(out), nil

}

Design points worth locking down:

  • A fresh nonce every time, from crypto/rand. Reusing a nonce with the same key collapses GCM's security entirely. math/rand is never acceptable
  • Add a prefix. A single aes-gcm: marker tells you whether a value is encrypted, and lets encrypted values coexist with legacy plaintext during a gradual migration
  • Never swallow a decryption failure. It means the key is wrong or the data is corrupt, so it must surface as an error. Returning plaintext there defeats your own integrity check
  • Keep the encryption key out of the database. Environment variable or secrets manager. Storing it alongside the ciphertext makes the encryption pointless

Separately, audit whether keys leak into logs, error messages, or API responses. Encrypting perfectly and then spilling the value through a debug log is a common incident. Safest is to omit key fields from response structs entirely, or mask them.

5. RBAC and Session Separation

Tenant isolation is horizontal (between customers); RBAC is vertical (between roles inside one customer). You need both.

type Role string

const (

    RoleOwner  Role = "owner"   // everything, including billing and members

    RoleAdmin  Role = "admin"   // agent and provider configuration

    RoleMember Role = "member"  // use the agents

    RoleViewer Role = "viewer"  // read-only

)

func Require(ctx context.Context, need Role) error {

    have, err := RoleFrom(ctx)

    if err != nil {

        return err                    // unknown role → deny (fail-closed)

    }

    if rank[have] < rank[need] {

        return ErrForbidden

    }

    return nil

}

Sessions need splitting too. Key a session on session_id alone and anyone from another tenant who learns that ID gets the conversation history. Make the uniqueness key (tenant_id, agent_id, user_id, channel). Then the same user talking to the same agent from Telegram and from the web keeps two separate, unmixed contexts.

6. Pre-Deployment Checklist

  • Does calling a storage function with a tenant-less context error out? (pin it with a test)
  • Does every table have tenant_id, and is it the leading index column?
  • Does a request carrying another tenant's resource ID get 404/403?
  • Have you actually fed ../, absolute paths, and symlinks into the file tools?
  • Are keys absent in plaintext from the database, logs, and API responses?
  • Does deleting a tenant also remove its workspace files?

Summary

  • Propagate tenancy through context and error when it's missing — fail-closed is the whole idea
  • SQLite is fine. Turn on WAL mode and lead indexes with tenant_id
  • Validate workspace paths by resolving symlinks then using filepath.Rel — prefix comparison is bypassable
  • Encrypt keys with AES-256-GCM, fresh nonce each time, a prefix marking encrypted values, and decryption failure as an error
  • Horizontal isolation (tenant) and vertical isolation (RBAC) are separate concerns — you need both
  • Key sessions on (tenant, agent, user, channel)

The hard part of multi-tenancy isn't the technology—it's not forgetting. Which is why you build a structure where mistakes get blocked. A storage layer that compiles fine but reliably dies at runtime when called without a tenant: that one layer prevents most of the incidents you'd otherwise have later.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기