Permissions¶
CyberEco implements a comprehensive permission system with a 4-tier role hierarchy, per-app feature access, resource-level permissions, and conditional access constraints. Permissions are enforced automatically by the DataLayerService on every read and write operation.
Role Hierarchy¶
CyberEco uses a 4-tier role hierarchy where each tier includes all capabilities of the tiers below it:
owner (4) Full control, can manage admins
|
admin (3) Can manage members, settings, and content
|
moderator (2) Can manage content and moderate interactions
|
member (1) Basic read/write access
Role Values¶
Defined in @cyber-eco/types:
type AppRole = 'owner' | 'admin' | 'moderator' | 'member';
const ROLE_HIERARCHY: Record<AppRole, number> = {
owner: 4,
admin: 3,
moderator: 2,
member: 1,
};
hasMinimumRole()¶
The hasMinimumRole() helper compares a user's role against a required minimum:
import { hasMinimumRole } from '@cyber-eco/types';
hasMinimumRole('admin', 'member'); // true (3 >= 1)
hasMinimumRole('member', 'admin'); // false (1 < 3)
hasMinimumRole('owner', 'owner'); // true (4 >= 4)
hasMinimumRole('moderator', 'admin'); // false (2 < 3)
This is used internally by PermissionService.hasRole() to determine whether a user meets a role requirement for a given app.
AppPermission Model¶
Each user's permissions are stored as an array of AppPermission objects on their user document:
interface AppPermission {
appId: string; // Which app this permission applies to
roles: AppRole[]; // Roles the user has in this app
features: string[]; // Specific features the user can access
grantedAt: string; // ISO timestamp of when access was granted
grantedBy: string; // User ID of who granted access
conditions?: PermissionCondition[]; // Optional conditional constraints
}
Example User Document¶
{
"id": "user-123",
"displayName": "Alice",
"apps": ["justsplit", "hub"],
"permissions": [
{
"appId": "justsplit",
"roles": ["admin"],
"features": ["expenses", "settlements", "reports"],
"grantedAt": "2024-06-15T10:30:00Z",
"grantedBy": "user-001"
},
{
"appId": "hub",
"roles": ["member"],
"features": ["profile", "settings"],
"grantedAt": "2024-06-01T08:00:00Z",
"grantedBy": "system"
}
]
}
PermissionService¶
The PermissionService is a domain service that manages all permission operations. It is created automatically by the createDataLayer() factory.
Accessing PermissionService¶
import { createDataLayer } from '@cyber-eco/services';
const { permissions } = createDataLayer({ adapter, permissions: { enabled: true } });
App Access¶
Check Access¶
const hasAccess = await permissions.hasAppAccess('user-123', 'justsplit');
// true if user has the app in their apps[] array, or if they are a system admin
Grant Access¶
import type { AppRole } from '@cyber-eco/types';
await permissions.grantAppAccess(
'user-456', // userId - who gets access
'justsplit', // appId
['member'] as AppRole[], // roles to grant
'user-001', // grantedBy - who is granting access
['expenses', 'view-reports'], // features to enable
);
Merging behavior
If the user already has a permission entry for the app, grantAppAccess merges the new roles and features with the existing ones rather than replacing them.
Revoke Access¶
await permissions.revokeAppAccess(
'user-456', // userId - who loses access
'justsplit', // appId
'user-001', // revokedBy - who is revoking access
);
Revoking removes the app from the user's apps[] array and removes the corresponding AppPermission entry entirely.
Role Checks¶
Check if User Has a Role¶
// Returns true if user has the exact role OR a higher role
const isAdmin = await permissions.hasRole('user-123', 'justsplit', 'admin');
The hasRole method uses hasMinimumRole() internally, so a user with the owner role will pass a check for admin, moderator, or member.
Add a Role¶
Prerequisite
The user must already have an AppPermission entry for the app. Call grantAppAccess first if needed.
Feature Checks¶
const canExport = await permissions.hasAppFeature('user-123', 'justsplit', 'reports');
// true if 'reports' is in the user's features array for the 'justsplit' app
System Admins¶
Users with isAdmin: true on their user document bypass all permission checks:
hasAppAccessreturnstruefor any apphasRolereturnstruefor any rolehasAppFeaturereturnstruefor any feature
Conditional Access¶
The PermissionCondition interface supports attaching constraints to permissions that are evaluated at check time:
interface PermissionCondition {
type: 'time' | 'ip' | 'mfa' | 'custom';
config: Record<string, unknown>;
}
Condition Types¶
Restrict access to specific hours or date ranges:
const permission: AppPermission = {
appId: 'trading-app',
roles: ['member'],
features: ['trade'],
grantedAt: '2024-01-01T00:00:00Z',
grantedBy: 'admin-1',
conditions: [
{
type: 'time',
config: {
startHour: 9,
endHour: 17,
timezone: 'America/New_York',
// Access only during business hours
},
},
],
};
Limit access to specific IP ranges:
Require multi-factor authentication:
Application-specific conditions:
Condition evaluation
Condition evaluation is currently the responsibility of the consuming application. The PermissionCondition type provides a standard schema for storing and communicating conditions. Future versions of the PermissionService will include built-in condition evaluators.
Resource-Level Permissions¶
Beyond app-level roles, CyberEco supports fine-grained resource-level permissions:
interface ResourcePermission {
id: string;
resourceType: string; // Collection name (e.g., 'documents', 'groups')
resourceId: string; // Specific document ID
userId: string; // Who has access
permissions: string[]; // What they can do: ['read', 'write', 'delete']
grantedBy: string;
grantedAt: string;
expiresAt?: string; // Optional expiration
}
Grant Resource Permission¶
await permissions.grantResourcePermission(
'user-456', // userId
'documents', // resourceType (collection)
'doc-789', // resourceId
['read', 'write'], // permissions
'user-001', // grantedBy
'2025-12-31T23:59:59Z', // optional expiresAt
);
Check Resource Access¶
const canRead = await permissions.canAccessResource(
'user-456', // userId
'documents', // resourceType
'doc-789', // resourceId
'read', // permission to check
);
Access Resolution Order¶
When canAccessResource is called, it checks in this order:
- Explicit resource permission: Check
ResourcePermissiondocuments for the user + resource combination. If found, verify the permission string is included and the grant has not expired. - Ownership: Check if the user is the
ownerId,userId, orcreatedByof the resource document. Owners have full access. - Group membership: If the resource has a
groupId, look up the group's member list. Owners and admins get full access, members get read access, and moderators get read + write access. - Default deny: If none of the above match, return
false.
DataLayerService Integration¶
The DataLayerService automatically calls the permission checker before every operation:
core.get(userId, collection, id)
|
+-- checkPermission(userId, collection, 'read', id)
| |
| +-- permissionChecker(userId, collection, 'read', id)
| | returns true -> proceed to adapter
| | returns false -> throw AuthorizationError
|
+-- adapter.getDocument(collection, id)
+-- return result
Permission Actions¶
| Operation | Action Checked |
|---|---|
core.get() |
read |
core.query() |
read |
core.subscribe() |
read |
core.create() |
write |
core.update() |
write |
core.delete() |
delete |
core.batchWrite() |
Per-operation (write or delete) |
Disabling Permissions¶
For development or testing, permissions can be disabled:
const dataLayer = createDataLayer({
adapter,
permissions: { enabled: false }, // Skip all permission checks
});
Production warning
Never disable permissions in production. All data operations will succeed regardless of who the caller is.
The Bootstrap Problem¶
There is a circular dependency between DataLayerService and PermissionService:
DataLayerServiceneeds a permission checker to validate operationsPermissionServiceneedsDataLayerServiceto read user/permission data
The createDataLayer() factory solves this with a three-step wiring process:
export function createDataLayer(config: DataLayerConfig): CyberEcoDataLayer {
// Step 1: Create core WITHOUT permission checking
const core = new DataLayerService(config);
// Step 2: Create PermissionService that uses core for data access
const permissions = new PermissionService(core);
// Step 3: Wire permission checking BACK into core
core.setPermissionChecker(permissions.evaluatePermission.bind(permissions));
// Step 4: Create remaining domain services
return {
core,
permissions,
sharedData: new SharedDataService(core),
notifications: new NotificationService(core),
dashboard: new DashboardService(core),
dataExport: new DataExportService(core),
};
}
evaluatePermission
PermissionService.evaluatePermission() is the method wired into DataLayerService. It handles the logic of checking app access, resource permissions, and ownership. Special case: users always have read access to their own user document.
Audit Logging¶
All permission changes are automatically logged to the permission_logs collection:
// View permission audit trail
const logs = await permissions.getPermissionLogs('user-123', 50);
// Returns the 50 most recent permission changes, sorted by timestamp descending
Each log entry contains:
{
userId: string; // Which user's permissions changed
appId: string; // Which app was affected
action: 'grant' | 'revoke' | 'modify';
features: string[]; // Features affected
grantedBy: string; // Who made the change
timestamp: string; // When the change occurred
}
Next Steps¶
- StorageAdapter Pattern -- How the adapter enables this permission model
- Testing Guide -- Testing permission logic with MockStorageAdapter
- Architecture -- Full system design including permissions flow