Skip to main content

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 control
  • packages/agent/src/plugins/entities/work-plugin.entity.ts -- Per-work plugin configuration
  • packages/agent/src/plugins/services/plugin-settings.service.ts -- Multi-level settings resolution
  • packages/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
AspectIsolation LevelMechanism
Content (items, categories)FullForeign key to workId
Plugin settingsLayeredWorkPluginEntity overrides
Capability routingIndependentactiveCapability per work-plugin
Access controlPer-workWorkOwnershipService + roles
Cache / checkpointsScopedCache key includes workId
Generation historyPer-workScoped to work

Role-Based Access Control

Role Hierarchy

The WorkOwnershipService enforces a four-level role hierarchy:

RoleLevelPermissions
VIEWER1View work content
EDITOR2View + edit content, trigger generation
MANAGER3View + edit + manage members
OWNER4Full 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:

PrioritySourceStorageScope
1 (highest)Workwork_plugins.settingsPer work
2Useruser_plugins.settingsPer user
3Adminplugins.settingsGlobal
4Environmentprocess.env[x-envVar]Server-wide
5 (lowest)DefaultJSON Schema defaultBuilt-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:

ModeWork SettingsUser SettingsAdmin SettingsUse Case
hybridYesYesYesMost plugins (OpenRouter, Scrapfly)
user-requiredYesYes (required)NoPer-user API keys (Mistral, Google)
admin-onlyNoNoYesSystem-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:

ColumnContentAPI Response
settingsNon-sensitive configurationIncluded
secretSettingsAPI keys, tokens, credentialsMasked 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 TypeKey FormatExample
Pipeline checkpointpipeline-checkpoint-{workId}-{pipelineId}pipeline-checkpoint-dir123-standard-pipeline
Plugin cacheplugin:{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

BoundaryWhat Is IsolatedHow
PlatformPlugin code, registryShared singleton services
UserAPI keys, user settingsUserPluginEntity, user-scoped resolution
WorkContent, settings, membersWorkPluginEntity, foreign keys, RBAC
PluginCache, logger, eventsNamespace-prefixed keys, scoped context

Best Practices

  1. Always pass workId and userId to settings resolution: Omitting these causes the service to skip higher-priority settings levels
  2. Use ensureCanEdit before mutations: All content-changing operations should verify at least EDITOR role
  3. Never store secrets in settings: Use secretSettings for API keys and tokens so they are masked in API responses
  4. Scope cache keys to work: When writing custom plugins that cache data, include the work ID in cache keys to prevent cross-work leakage
  5. Respect configurationMode: Plugins that declare admin-only should not accept user-level or work-level settings overrides