Skip to content

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:

packages/types/src/permissions.ts
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:

packages/types/src/permissions.ts
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

await permissions.addUserRole('user-123', 'justsplit', 'moderator');

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:

  • hasAppAccess returns true for any app
  • hasRole returns true for any role
  • hasAppFeature returns true for any feature
{
  "id": "super-admin",
  "displayName": "System Admin",
  "isAdmin": true
}

Conditional Access

The PermissionCondition interface supports attaching constraints to permissions that are evaluated at check time:

packages/types/src/permissions.ts
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:

const permission: AppPermission = {
  appId: 'internal-tool',
  roles: ['admin'],
  features: ['deploy'],
  grantedAt: '2024-01-01T00:00:00Z',
  grantedBy: 'system',
  conditions: [
    {
      type: 'ip',
      config: {
        allowedRanges: ['10.0.0.0/8', '192.168.1.0/24'],
      },
    },
  ],
};

Require multi-factor authentication:

const permission: AppPermission = {
  appId: 'admin-panel',
  roles: ['admin'],
  features: ['manage-users', 'billing'],
  grantedAt: '2024-01-01T00:00:00Z',
  grantedBy: 'system',
  conditions: [
    {
      type: 'mfa',
      config: {
        required: true,
        methods: ['totp', 'webauthn'],
      },
    },
  ],
};

Application-specific conditions:

const permission: AppPermission = {
  appId: 'marketplace',
  roles: ['member'],
  features: ['sell'],
  grantedAt: '2024-01-01T00:00:00Z',
  grantedBy: 'system',
  conditions: [
    {
      type: 'custom',
      config: {
        rule: 'account-age',
        minDays: 30,
        // User must have an account for at least 30 days
      },
    },
  ],
};

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:

packages/types/src/permissions.ts
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:

  1. Explicit resource permission: Check ResourcePermission documents for the user + resource combination. If found, verify the permission string is included and the grant has not expired.
  2. Ownership: Check if the user is the ownerId, userId, or createdBy of the resource document. Owners have full access.
  3. 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.
  4. 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:

  • DataLayerService needs a permission checker to validate operations
  • PermissionService needs DataLayerService to read user/permission data

The createDataLayer() factory solves this with a three-step wiring process:

packages/services/src/factories/createDataLayer.ts
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