How Paperclip Enforces Company-Scoped Data Isolation and Multi-Tenancy

Paperclip implements single-tenant deployment with a multi-company data model, where every database record carries a company_id foreign-key that acts as a hard isolation boundary enforced across authentication, middleware, services, and storage layers.

Paperclip's architecture separates customer data through a company-scoped data isolation model rather than running separate infrastructure per tenant. This approach allows the platform to host multiple organizations on shared compute and storage while maintaining strict guarantees that no company can access another's data. The enforcement relies on a company_id column present in every core table, propagated through request contexts, and validated at multiple architectural layers.

The Schema Foundation: company_id as the Isolation Boundary

Every business record in PostgreSQL belongs to exactly one company. The schema design—documented in doc/SPEC-implementation.md §7.1—mandates that all tables except companies itself include a company_id foreign key referencing companies.id.

Entity Primary key Company foreign-key
companies id —
agents id company_id
projects id company_id
issues id company_id
cost_events id company_id
activity_log id company_id

Foreign-key constraints at the database level prevent orphaned records and enforce referential integrity. This schema-level guarantee is the first line of defense in Paperclip's multi-tenancy enforcement.

Authentication Context: Establishing Company Identity

Two authentication paths populate the company context differently.

Board Users

Board users authenticate via session cookies. The session contains the list of companies the board can administer. Board members implicitly have access to all companies in the deployment, making them super-tenant operators rather than single-tenant users.

Agent API Keys

Agents authenticate with bearer tokens derived from API keys. The agent_api_keys table (§7.8 of the implementation spec) stores the company_id directly on the key record. The JWT validator in server/src/agent-auth-jwt.ts extracts this value:

// server/src/agent-auth-jwt.ts
const payload = verifyJwt(token);
req.agent = {
  id: payload.sub,
  companyId: payload.companyId, // bound to the key at creation time
};

The companyId is attached to the request object as req.companyId, making it available to downstream middleware and services.

Request-Level Middleware: Resolving the Active Company

The middleware layer in server/src/paths.ts determines which company a request targets. It supports two resolution strategies:

  1. URL prefix parsing — Routes like /PAP/... encode the company identifier in the path
  2. Auth context fallback — Falls back to the company embedded in the authenticated session or JWT

The UI-side helper normalizeCompanyPrefix in ui/src/plugins/bridge.ts ensures consistent formatting:

// ui/src/plugins/bridge.ts
export function resolveHostNavigationHref(to: string, companyPrefix: string | null) {
  if (!companyPrefix) return to;
  return `/${normalizeCompanyPrefix(companyPrefix)}${to}`;
}

Once resolved, req.companyId is populated and propagated throughout the request lifecycle.

Service-Layer Enforcement: Query-Scoped Data Access

Every database query includes an explicit company_id filter. Services receive companyId from the request context and apply it unconditionally. The issue-service.ts pattern demonstrates this:

// server/src/services/issue-service.ts
export async function getIssue(db: Db, issueId: string, companyId: string) {
  return db
    .select()
    .from(issues)
    .where(eq(issues.id, issueId))
    .and(eq(issues.company_id, companyId)); // hard boundary
}

This pattern repeats across all CRUD operations. There is no "global" query path that omits the company filter—every service function requires the parameter explicitly.

Storage-Layer Isolation: Object Key Prefixing

File storage enforces company boundaries through key naming conventions. The ensureCompanyPrefix function in server/src/storage/types.ts validates that object keys begin with the correct companyId/ prefix:

// server/src/storage/types.ts
function ensureCompanyPrefix(companyId: string, objectKey: string) {
  const expectedPrefix = `${companyId}/`;
  if (!objectKey.startsWith(expectedPrefix)) {
    throw unprocessable(`objectKey must start with ${expectedPrefix}`);
  }
}

Attempting to access an object with a mismatched prefix fails with an unprocessable error. This prevents cross-company asset leakage even if higher layers were compromised.

Authorization Matrix: Who Can Access What

Paperclip's permission model applies after company scoping, as documented in §9.2 and §9.3 of the spec:

  • Board: Can read/write any record across all companies. All actions are logged to activity_log with the company_id preserved for audit trails.
  • Agent: May only act on resources where company_id matches the agent's key-bound company. Even default-open permissions like tasks:assign are evaluated within this constraint.

The company check precedes permission evaluation—an agent with broad permissions cannot escape its company boundary.

Defense in Depth: Multi-Layer Enforcement

Layer Enforcement Mechanism
Database schema company_id foreign keys on all tables; FK constraints
Authentication companyId embedded in JWT for agents; session list for board
Middleware req.companyId resolution from URL or auth context
Services Explicit where company_id = :companyId in every query
Storage Object key prefix validation (ensureCompanyPrefix)
Authorization Permission matrix evaluated within company scope

No single layer constitutes the entire security model. Each layer provides redundant validation, ensuring that a bug or misconfiguration in one component cannot breach company isolation.

Summary

  • Company-scoped data isolation in Paperclip relies on a mandatory company_id foreign key present in every business table
  • Multi-tenancy enforcement operates through four layers: schema design, authentication context, request middleware, and service-layer query filtering
  • Agents are bound to a single company via their API key's company_id, embedded in JWT claims by server/src/agent-auth-jwt.ts
  • Board users operate across all companies but still respect the schema boundary for audit and organizational purposes
  • Storage isolation uses companyId/ key prefixes validated by ensureCompanyPrefix in server/src/storage/types.ts
  • Authorization never overrides the company boundary—permissions are evaluated only within the scoped context

Frequently Asked Questions

How does Paperclip prevent one company from accessing another's data?

Every database query includes a where company_id = :companyId clause populated from the authenticated request context. This pattern is enforced in all services, with no exceptions for "admin" queries. The company_id is extracted from agent API keys during JWT validation or from the session for board users, then propagated through middleware to every database call.

What happens if a request tries to access storage with the wrong company prefix?

The ensureCompanyPrefix function in server/src/storage/types.ts throws an unprocessable error before any storage operation executes. This prevents cross-company file access even if the application layer were misconfigured to request the wrong object key.

Can a single agent work across multiple companies?

No. Each agent_api_keys record is bound to exactly one company_id at creation time. This value is embedded in the JWT payload and cannot be modified by the agent. A board member can act across companies, but agents are strictly single-company scoped.

Does Paperclip use separate databases or schemas per company?

No. Paperclip uses single-tenant deployment with a multi-company data model—one PostgreSQL database with company_id columns partitioning the data logically. This differs from schema-per-tenant or database-per-tenant architectures, reducing operational complexity while maintaining isolation through application-layer enforcement.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →