@levante-framework/permissions-core
v1.1.2
Published
Shared permissions service for front-end and back-end
Downloads
148
Readme
Permissions Service
A TypeScript package implementing a resource-based access control system for multi-site platforms. Designed for use in both frontend (Vue SPA) and backend (Firebase Cloud Functions) environments.
Features
- Multi-site Support: Site-scoped permissions with super admin global access
- Role Hierarchy: Five-tier role system from participant to super admin
- Resource-based Access Control: Granular permissions with nested sub-resources for groups and admins
- Caching: TTL-based caching with user-specific clearing and automatic cleanup
- Decision Logging (opt-in): Configurable modes with pluggable sinks for observability
- Version Management: Document validation and migration framework
- TypeScript: Full type safety with comprehensive interfaces
- ESM: Modern ES module support with source maps
Installation
npm install permissions-serviceQuick Start
Basic Usage
import { PermissionService, CacheService } from 'permissions-service';
const cache = new CacheService();
const loggingConfig = { mode: 'off' as const }; // 'off' | 'baseline' | 'debug'
const sink = {
isEnabled: () => loggingConfig.mode !== 'off',
emit: (event) => {
// no-op by default; plug in Firestore, console, etc.
}
};
const permissions = new PermissionService(cache, loggingConfig, sink);
// Check if user can perform action on a nested resource
const canEdit = permissions.canPerformSiteAction(
user,
'site456',
'groups',
'update',
'schools' // sub-resource required for groups
);
if (canEdit) {
// User can edit schools
}Cloud Functions Integration
// functions/src/permissions.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { getFirestore } from 'firebase-admin/firestore';
import { PermissionService, CacheService } from 'permissions-service';
// Module-level cache for container persistence
const cache = new CacheService();
const loggingConfig = {
mode: process.env.PERM_LOG_MODE ?? 'baseline'
};
const firestoreSink = {
isEnabled: () => loggingConfig.mode !== 'off',
emit: (event) => {
setImmediate(async () => {
await getFirestore()
.collection('permission_events')
.add({ ...event, expireAt: Date.now() + 1000 * 60 * 60 * 24 * 90 }); // 90-day TTL
});
}
};
export const updateGroup = onCall(async (request) => {
const permissions = new PermissionService(cache, loggingConfig, firestoreSink);
const { userId, siteId } = request.auth;
// Check permission
const canUpdate = await permissions.hasPermission(
userId,
siteId,
'groups',
'update'
);
if (!canUpdate) {
throw new HttpsError('permission-denied', 'Insufficient permissions');
}
// Proceed with update
});Vue SPA Integration
// composables/usePermissions.ts
import { PermissionService, CacheService } from 'permissions-service';
import { ref, computed } from 'vue';
// Session-level cache
const cache = new CacheService();
const permissions = new PermissionService(cache);
export function usePermissions() {
const currentUser = ref(null);
const currentSite = ref(null);
const canCreateGroups = computed(async () => {
if (!currentUser.value || !currentSite.value) return false;
return await permissions.hasPermission(
currentUser.value.id,
currentSite.value.id,
'groups',
'create'
);
});
return {
canCreateGroups,
hasPermission: permissions.hasPermission.bind(permissions)
};
}Logging & Observability
Permission decisions remain boolean for callers, but you can enable structured logging by supplying a LoggingModeConfig and sink:
import { PermissionService, CacheService } from 'permissions-service';
const cache = new CacheService();
const loggingConfig = { mode: 'baseline' as const };
const sink = {
isEnabled: () => loggingConfig.mode !== 'off',
emit: (event) => {
// Persist to Firestore, enqueue to Pub/Sub, etc.
// Keep payloads de-identified (avoid IP / user agent).
}
};
const permissions = new PermissionService(cache, loggingConfig, sink);Recommended sink patterns:
Firestore (backend) — write each event with a TTL:
const FirestoreSink = { isEnabled: () => true, emit: (event) => { setImmediate(async () => { await db.collection('permission_events').add({ ...event, expireAt: Date.now() + 1000 * 60 * 60 * 24 * 60 // 60 days }); }); } };Beacon (frontend) — forward sampled events to an HTTPS endpoint:
const BrowserSink = { isEnabled: () => true, emit: (event) => { const { userId, ...sanitized } = event; // strip identifiers if required navigator.sendBeacon('/api/permission-log', JSON.stringify(sanitized)); } };
Toggle logging modes via environment variables or Remote Config ('off' → no emission, 'baseline' → minimal denies, 'debug' → full capture for investigations). Return to 'off' once debugging is complete to avoid unnecessary overhead.
Role Hierarchy
The system implements a five-tier role hierarchy:
participant- No admin dashboard accessresearch_assistant- Read access + user creationadmin- Subset of actions within their sitesite_admin- Full control over their site's resourcessuper_admin- Full system access across all sites
Nested Permissions Structure
The permission system uses nested sub-resources for groups and admins:
Group Sub-Resources:
sites- Site-level groupsschools- School-level groupsclasses- Class-level groupscohorts- Cohort-level groups
Admin Sub-Resources:
site_admin- Site administrator accountsadmin- Admin accountsresearch_assistant- Research assistant accounts
Flat Resources:
assignments- Task assignmentsusers- User accountstasks- System tasks
Permission Matrix Example
{
"admin": {
"groups": {
"sites": ["read", "update"],
"schools": ["read", "update", "delete"],
"classes": ["read", "update", "delete"],
"cohorts": ["read", "update", "delete"]
},
"admins": {
"site_admin": ["read"],
"admin": ["read"],
"research_assistant": ["create", "read"]
},
"assignments": ["create", "read", "update", "delete"],
"users": ["create", "read", "update"],
"tasks": ["read"]
}
}API Reference
PermissionService
Constructor
new PermissionService(
cache?: CacheService,
loggingConfig?: LoggingModeConfig,
sink?: PermEventSink
)loggingConfigdefaults to{ mode: 'off' }.sinkdefaults to the internal no-op sink; callers can supply Firestore/beacon/etc.
Methods
canPerformSiteAction(user, siteId, resource, action, subResource?)
Check if a user has permission to perform an action on a resource within a site.
// Nested resource (requires sub-resource)
const canEditSchools = permissions.canPerformSiteAction(
user,
'site456',
'groups',
'update',
'schools' // required for nested resources
);
// Flat resource (no sub-resource needed)
const canEditUsers = permissions.canPerformSiteAction(
user,
'site456',
'users',
'update'
);canPerformGlobalAction(user, resource, action, subResource?)
Check if a super admin can perform a global action.
const canManageAdmins = permissions.canPerformGlobalAction(
superAdminUser,
'admins',
'delete',
'admin'
);bulkPermissionCheck(user, siteId, checks)
Bulk permission checking for multiple resource/action combinations.
const results = permissions.bulkPermissionCheck(user, 'site456', [
{ resource: 'groups', action: 'create', subResource: 'schools' },
{ resource: 'users', action: 'read' }
]);
// Returns: [{ resource: 'groups', action: 'create', subResource: 'schools', allowed: true }, ...]getAccessibleResources(user, siteId, action)
Get flat resources the user can perform an action on.
const resources = permissions.getAccessibleResources(user, 'site456', 'create');
// Returns: ['assignments', 'users'] (only flat resources)getAccessibleGroupSubResources(user, siteId, action)
Get group sub-resources the user can perform an action on.
const groupTypes = permissions.getAccessibleGroupSubResources(user, 'site456', 'create');
// Returns: ['schools', 'classes', 'cohorts']getAccessibleAdminSubResources(user, siteId, action)
Get admin sub-resources the user can perform an action on.
const adminTypes = permissions.getAccessibleAdminSubResources(user, 'site456', 'create');
// Returns: ['research_assistant']getUserRole(userId, siteId)
Get the user's role for a specific site.
const role = await permissions.getUserRole('user123', 'site456');
// Returns: 'admin' | 'site_admin' | etc.clearUserCache(userId)
Clear cached data for a specific user.
await permissions.clearUserCache('user123');CacheService
Constructor
new CacheService(defaultTtl?: number) // Default: 5 minutesMethods
get(key)
Retrieve cached value.
const value = cache.get('user:123:permissions');set(key, value, ttl?)
Store value in cache with optional TTL.
cache.set('user:123:permissions', permissions, 300000); // 5 minutesdelete(key) / clear()
Remove specific key or clear entire cache.
cache.delete('user:123:permissions');
cache.clear();User Data Structure
Users must have the following structure in Firestore:
interface User {
id: string;
roles: Array<{
siteId: string;
role: 'participant' | 'research_assistant' | 'admin' | 'site_admin' | 'super_admin';
}>;
userType?: 'admin' | 'student' | 'teacher' | 'caregiver';
}Permission Matrix Document
The system expects a permission matrix document with nested structure:
interface PermissionMatrix {
[role: string]: {
groups: {
sites: Action[];
schools: Action[];
classes: Action[];
cohorts: Action[];
};
admins: {
site_admin: Action[];
admin: Action[];
research_assistant: Action[];
};
assignments: Action[];
users: Action[];
tasks: Action[];
};
}
interface PermissionDocument {
permissions: PermissionMatrix;
version: string;
updatedAt: string;
}Example Document (stored at system/permissions):
{
"permissions": {
"site_admin": {
"groups": {
"sites": ["read", "update"],
"schools": ["create", "read", "update", "delete", "exclude"],
"classes": ["create", "read", "update", "delete", "exclude"],
"cohorts": ["create", "read", "update", "delete", "exclude"]
},
"assignments": ["create", "read", "update", "delete", "exclude"],
"users": ["create", "read", "update", "delete", "exclude"],
"admins": {
"site_admin": ["create", "read"],
"admin": ["create", "read", "update", "delete", "exclude"],
"research_assistant": ["create", "read", "update", "delete"]
},
"tasks": ["create", "read", "update", "delete", "exclude"]
}
},
"version": "1.1.0",
"updatedAt": "2025-09-29T00:00:00Z"
}Error Handling
The service throws specific errors for different scenarios:
try {
const canEdit = await permissions.hasPermission(userId, siteId, 'groups', 'update');
} catch (error) {
if (error.message.includes('User not found')) {
// Handle missing user
} else if (error.message.includes('Permission matrix not found')) {
// Handle missing configuration
}
}Performance Considerations
Caching Strategy
- Frontend: Session-level cache, cleared on user/site changes
- Backend: Module-level cache for container persistence
- TTL: Default 5 minutes, configurable per cache instance
- Bulk Operations: Use
hasPermissions()for multiple checks
Best Practices
- Reuse Cache Instances: Create once per session/container
- Bulk Checks: Use
hasPermissions()for multiple permission checks - Clear Cache: Clear user cache after role changes
- Error Handling: Always handle permission check failures gracefully
Development
Build
npm run build # Compile TypeScript
npm run dev # Watch mode
npm run clean # Remove dist directoryTesting
npm test # Run tests in watch mode
npm run test:run # Run tests oncePackage Testing
npm pack # Create tarball for local testingMigration from Organization-based Permissions
This package replaces organization-based permissions with resource-based permissions. Key changes:
- Roles are now site-scoped instead of organization-scoped
- Permissions are defined per resource/action combination
- Super admin role provides global access across all sites
- No permission management UI (roles are backend-managed)
License
TBD
