Multi-Tenancy & Isolation
Ever Works isolates data and configuration at the work level. Each work acts as an independent workspace with its own plugin settings, capability routing, member permissions, and generated content. This page documents the isolation boundaries, role-based access control, and settings scoping that make multi-tenancy work.
Key sources:
packages/agent/src/services/work-ownership.service.ts-- Role-based access controlpackages/agent/src/plugins/entities/work-plugin.entity.ts-- Per-work plugin configurationpackages/agent/src/plugins/services/plugin-settings.service.ts-- Multi-level settings resolutionpackages/agent/src/plugins/services/plugin-context-factory.service.ts-- Scoped plugin contexts
Architecture
Work as Tenant
The work is the primary isolation boundary in Ever Works. Each work:
- Has its own set of items, categories, and tags
- Can override plugin settings at the work level
- Routes capabilities independently (e.g., Work A uses GitHub, Work B uses GitLab)
- Has its own member list with role-based permissions
- Maintains separate pipeline checkpoints
| Aspect | Isolation Level | Mechanism |
|---|---|---|
| Content (items, categories) | Full | Foreign key to workId |
| Plugin settings | Layered | WorkPluginEntity overrides |
| Capability routing | Independent | activeCapability per work-plugin |
| Access control | Per-work | WorkOwnershipService + roles |
| Cache / checkpoints | Scoped | Cache key includes workId |
| Generation history | Per-work | Scoped to work |
Role-Based Access Control
Role Hierarchy
The WorkOwnershipService enforces a four-level role hierarchy:
| Role | Level | Permissions |
|---|---|---|
VIEWER | 1 | View work content |
EDITOR | 2 | View + edit content, trigger generation |
MANAGER | 3 | View + edit + manage members |
OWNER | 4 | Full control including deletion and transfer |
Access Check Methods
// Check minimum role requirement
await ownershipService.ensureCanView(workId, userId); // VIEWER+
await ownershipService.ensureCanEdit(workId, userId); // EDITOR+
await ownershipService.ensureCanManageMembers(workId, userId); // MANAGER+
await ownershipService.ensureIsOwner(workId, userId); // OWNER only
// Non-throwing check
const hasAccess = await ownershipService.hasAccess(workId, userId);
// Get role without enforcing
const role = await ownershipService.getUserRole(workId, userId);
Creator Privilege
The work creator always has OWNER access, even without an explicit membership record:
const isCreator = work.userId === userId;
if (isCreator) {
return {
work,
member: null,
role: WorkMemberRole.OWNER,
isCreator: true
};
}
Access Result
Every access check returns a WorkAccessResult with full context:
interface WorkAccessResult {
work: Work; // The work entity
member: WorkMember | null; // Membership record (null for creator)
role: WorkMemberRole; // Effective role
isCreator: boolean; // Whether user is the original creator
}
Settings Isolation
Five-Level Resolution Hierarchy
Plugin settings resolve through five levels, with higher-priority levels overriding lower ones:
| Priority | Source | Storage | Scope |
|---|---|---|---|
| 1 (highest) | Work | work_plugins.settings | Per work |
| 2 | User | user_plugins.settings | Per user |
| 3 | Admin | plugins.settings | Global |
| 4 | Environment | process.env[x-envVar] | Server-wide |
| 5 (lowest) | Default | JSON Schema default | Built-in |
How Resolution Works
// Resolution order: work > user > admin > env > default
async getResolvedSettings(pluginId: string, options?: SettingsResolutionOptions) {
// 1. Check work settings (if workId provided)
if (options?.workId && sources.work[key] !== undefined) {
return { value, source: 'work' };
}
// 2. Check user settings (if userId provided)
if (options?.userId && sources.user[key] !== undefined) {
return { value, source: 'user' };
}
// 3. Check admin settings
if (sources.admin[key] !== undefined) {
return { value, source: 'admin' };
}
// 4. Check environment variable
if (envVar && process.env[envVar] !== undefined) {
return { value: parseEnvValue(process.env[envVar]), source: 'env' };
}
// 5. Fall back to default
return { value: defaultValue, source: 'default' };
}
Each resolved setting includes its source and whether it is a fallback:
interface ResolvedSetting {
key: string;
value: unknown;
source: 'work' | 'user' | 'admin' | 'env' | 'default';
isFallback: boolean; // true when source doesn't match setting's intended scope
}
Configuration Modes
Plugins declare how their settings can be managed:
| Mode | Work Settings | User Settings | Admin Settings | Use Case |
|---|---|---|---|---|
hybrid | Yes | Yes | Yes | Most plugins (OpenRouter, Scrapfly) |
user-required | Yes | Yes (required) | No | Per-user API keys (Mistral, Google) |
admin-only | No | No | Yes | System-level config |
When a plugin is admin-only, the settings service rejects user and work level updates:
if (configMode === 'admin-only') {
throw new Error(`Plugin "${pluginId}" is admin-only and cannot be configured by users`);
}
Scope Validation
Settings declare their intended scope via the x-scope schema extension. The service validates that updates happen at the correct level:
// work-scoped settings can only be set at work level
if (settingScope === 'work' && updateScope !== 'work') {
violations.push(`Setting "${key}" cannot be updated at "${updateScope}" level`);
}
// user-scoped settings cannot be set at global level
if (settingScope === 'user' && updateScope === 'global') {
violations.push(`Setting "${key}" cannot be updated at "global" level`);
}
Per-Work Plugin Configuration
The WorkPluginEntity stores work-specific plugin overrides:
@Entity({ name: 'work_plugins' })
@Unique(['workId', 'pluginId'])
class WorkPluginEntity {
workId: string; // Which work
pluginId: string; // Which plugin
enabled: boolean; // Plugin enabled for this work
activeCapability: string; // Active capability routing
settings: Record<string, unknown>; // Work-level settings
secretSettings: Record<string, unknown>; // Work-level secrets
metadata: Record<string, unknown>; // Integration state
priority: number; // Plugin priority in this work
}
Capability Routing
Each work can independently choose which plugin serves each capability:
The activeCapability field on WorkPluginEntity determines which plugin is active for a given capability within a work. Only one plugin can be active per capability per work.
Secret Isolation
Secrets (API keys, tokens) are stored separately from regular settings and are never included in API responses unless explicitly requested:
| Column | Content | API Response |
|---|---|---|
settings | Non-sensitive configuration | Included |
secretSettings | API keys, tokens, credentials | Masked as ******** |
The settings service strips masked placeholders on write to prevent overwriting real secrets with mask values:
if (propSchema?.['x-secret'] && value === MASKED_SECRET_PLACEHOLDER) {
continue; // Don't save the placeholder
}
Scoped Plugin Contexts
When a plugin executes, it receives a PluginContext scoped to the current work and user:
The context factory injects the userId and workId into the settings resolution, ensuring each plugin operation uses the correct settings for the current scope:
const settings = await this.settingsService.getSettings(pluginId, {
workId: context.workId,
userId: context.userId,
includeSecrets: true
});
Cache Key Scoping
All cache keys include the work ID to prevent cross-work data leakage:
| Cache Type | Key Format | Example |
|---|---|---|
| Pipeline checkpoint | pipeline-checkpoint-{workId}-{pipelineId} | pipeline-checkpoint-dir123-standard-pipeline |
| Plugin cache | plugin:{pluginId}:{key} | plugin:openrouter:models-list |
Pipeline checkpoints are fully scoped to the work, so resuming a failed generation in one work never affects another.
Data Isolation Summary
| Boundary | What Is Isolated | How |
|---|---|---|
| Platform | Plugin code, registry | Shared singleton services |
| User | API keys, user settings | UserPluginEntity, user-scoped resolution |
| Work | Content, settings, members | WorkPluginEntity, foreign keys, RBAC |
| Plugin | Cache, logger, events | Namespace-prefixed keys, scoped context |
Best Practices
- Always pass
workIdanduserIdto settings resolution: Omitting these causes the service to skip higher-priority settings levels - Use
ensureCanEditbefore mutations: All content-changing operations should verify at leastEDITORrole - Never store secrets in
settings: UsesecretSettingsfor API keys and tokens so they are masked in API responses - Scope cache keys to work: When writing custom plugins that cache data, include the work ID in cache keys to prevent cross-work leakage
- Respect
configurationMode: Plugins that declareadmin-onlyshould not accept user-level or work-level settings overrides