# How Paperclip Enforces Multi‑Company Data Isolation at the Database and API Level

> Learn how Paperclip enforces multi-company data isolation using database foreign keys, scoped queries, runtime validation, and strict API checks. Secure your SaaS data.

- Repository: [Paperclip/paperclip](https://github.com/paperclipai/paperclip)
- Tags: architecture
- Published: 2026-08-18

---

**Paperclip enforces multi‑company data isolation through mandatory `company_id` foreign keys on every domain table, Drizzle query builders that always scope results by company, storage‑key prefixes validated at runtime, and strict API‑level request validation that rejects any call missing a valid company context.**

Paperclip operates as a **single‑tenant control plane** hosting many independent companies, making robust isolation essential. According to the paperclipai/paperclip source code, the platform implements defense‑in‑depth across two layers: the database schema and query patterns, plus explicit validation in every API handler and service function.

---

## Database‑Level Isolation: Schema Design and Query Scoping

### Mandatory Company Foreign Keys

Every table storing mutable domain data includes a **non‑nullable `company_id` column** referencing the `companies` table. This pattern appears consistently across the schema definitions in `packages/db/src/schema/`:

- [`workspace_runtime_services.ts`](https://github.com/paperclipai/paperclip/blob/main/workspace_runtime_services.ts) — `companyId: uuid("company_id").notNull().references(() => companies.id)`
- [`workspace_operations.ts`](https://github.com/paperclipai/paperclip/blob/main/workspace_operations.ts) — same pattern with cascading deletes on company removal
- [`user_secret_definitions.ts`](https://github.com/paperclipai/paperclip/blob/main/user_secret_definitions.ts), [`tool_access.ts`](https://github.com/paperclipai/paperclip/blob/main/tool_access.ts), and others — identical `companyId` declarations

These foreign‑key constraints prevent orphaned records and guarantee referential integrity at the database level.

### Scoped Queries with Drizzle ORM

Every database query filters by `companyId` using Drizzle's `eq()` operator. In [`server/src/services/workspace-runtime-read-model.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/workspace-runtime-read-model.ts), lines 82 and 117 show this pattern:

```typescript
.where(eq(workspaceRuntimeServices.companyId, companyId))

```

No query in the codebase retrieves rows without this filter, ensuring **row‑level security by construction**.

### Object Storage Prefix Enforcement

The storage layer isolates blobs using **company‑prefixed keys**. The helper `ensureCompanyPrefix` in [`server/src/storage/service.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/storage/service.ts) (lines 46–52) validates every access:

```typescript
function ensureCompanyPrefix(companyId: string, objectKey: string) {
  const expectedPrefix = `${companyId}/`;
  if (!objectKey.startsWith(expectedPrefix)) {
    throw forbidden("Object does not belong to company");
  }
}

```

Attempts to access keys outside the company's prefix throw a 403 **forbidden** error, blocking cross‑company blob access even if a malicious client constructs a direct storage request.

---

## API‑Level Isolation: Request Validation and Context Propagation

### Typed Request Context

The Express type definitions in [`server/src/types/express.d.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/types/express.d.ts) augment the `Request` interface with company context:

```typescript
interface Request {
  companyId?: string;
  companyIds?: string[];
}

```

After authentication, middleware populates these fields, making the company scope available to every downstream handler.

### Explicit Company ID Validation

The `requireCompanyId` helper in [`server/src/services/plugin-secrets-handler.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/plugin-secrets-handler.ts) (line 32) enforces valid input:

```typescript
function requireCompanyId(companyId: unknown): string {
  if (typeof companyId !== "string" || !companyId.trim()) {
    throw unprocessable("companyId is required");
  }
  return companyId;
}

```

This function is invoked by **every public API endpoint** that operates on company‑scoped resources, rejecting malformed or missing identifiers with a 422 **unprocessable** response before any database access occurs.

### Service‑Layer Propagation

All service functions accept `companyId` as an explicit parameter and thread it into every operation. In [`server/src/services/workspace-runtime-leases.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/workspace-runtime-leases.ts) (line 97), lease lookups include:

```typescript
const leases = await db
  .select()
  .from(workspaceRuntimeServices)
  .where(eq(workspaceRuntimeServices.companyId, input.companyId));

```

The `companyId` flows from the HTTP request through validation helpers into database queries and storage calls, with no global state or implicit context that could be misconfigured.

---

## CLI and Plugin SDK Isolation

Automated agents and plugins cannot bypass company boundaries. The CLI in [`cli/src/commands/client/plugin.ts`](https://github.com/paperclipai/paperclip/blob/main/cli/src/commands/client/plugin.ts) uses the same `requireCompanyId` helper to validate user‑supplied company identifiers before embedding them in outbound API calls.

This ensures that **even programmatic clients** are forced into a specific company context and cannot act across tenant boundaries.

---

## Summary

- **Schema enforcement**: Every table requires `company_id` foreign keys with non‑nullable constraints.
- **Query scoping**: All Drizzle queries filter with `eq(...companyId, …)`, preventing cross‑tenant reads.
- **Storage isolation**: `ensureCompanyPrefix` validates object keys at runtime, throwing 403 for invalid prefixes.
- **API validation**: `requireCompanyId` rejects missing or malformed company identifiers with 422 errors.
- **Context propagation**: Validated `companyId` flows explicitly through middleware, handlers, services, and database calls.

Together, these layers guarantee that no user, agent, or plugin can access, modify, or address another company's data.

---

## Frequently Asked Questions

### What happens if a request omits the companyId parameter?

The API returns a **422 Unprocessable Entity** error. The `requireCompanyId` helper in [`server/src/services/plugin-secrets-handler.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/plugin-secrets-handler.ts) validates the presence and format of `companyId` before any database or storage operation executes, failing fast with a descriptive error message.

### Can a storage request bypass company isolation by crafting a direct object key?

No. The `ensureCompanyPrefix` function in [`server/src/storage/service.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/storage/service.ts) validates that every object key starts with the requesting company's ID prefix. Requests targeting keys outside this prefix throw a **403 Forbidden** error, regardless of authentication status or API permissions.

### How does Paperclip prevent accidental cross‑company queries in new code?

The Drizzle ORM pattern requires explicit `.where()` clauses for every query. Since all schema tables include `companyId` columns and all existing queries demonstrate the `eq(...companyId, …)` pattern, developers must consciously omit the filter to bypass isolation—making deviations obvious during code review.

### Is company isolation enforced for the CLI and automated agents?

Yes. The CLI and plugin SDK embed the same `requireCompanyId` validation used by the REST API. Automated agents must specify a valid company ID for every operation, and the server‑side enforcement layers prevent any cross‑tenant access even if a misconfigured client attempts it.