How Paperclip Enforces Multi‑Company Data Isolation at the Database and API Level
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—companyId: uuid("company_id").notNull().references(() => companies.id)workspace_operations.ts— same pattern with cascading deletes on company removaluser_secret_definitions.ts,tool_access.ts, and others — identicalcompanyIddeclarations
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, lines 82 and 117 show this pattern:
.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 (lines 46–52) validates every access:
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 augment the Request interface with company context:
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 (line 32) enforces valid input:
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 (line 97), lease lookups include:
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 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_idforeign keys with non‑nullable constraints. - Query scoping: All Drizzle queries filter with
eq(...companyId, …), preventing cross‑tenant reads. - Storage isolation:
ensureCompanyPrefixvalidates object keys at runtime, throwing 403 for invalid prefixes. - API validation:
requireCompanyIdrejects missing or malformed company identifiers with 422 errors. - Context propagation: Validated
companyIdflows 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 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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →