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:
- URL prefix parsing — Routes like
/PAP/...encode the company identifier in the path - 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_logwith thecompany_idpreserved for audit trails. - Agent: May only act on resources where
company_idmatches the agent's key-bound company. Even default-open permissions liketasks:assignare 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_idforeign 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 byserver/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 byensureCompanyPrefixinserver/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →