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.
- 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
npm install permissions-serviceimport { 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
}// 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
});// 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)
};
}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.
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
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
{
"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"]
}
}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.
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'
);Check if a super admin can perform a global action.
const canManageAdmins = permissions.canPerformGlobalAction(
superAdminUser,
'admins',
'delete',
'admin'
);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 }, ...]Get flat resources the user can perform an action on.
const resources = permissions.getAccessibleResources(user, 'site456', 'create');
// Returns: ['assignments', 'users'] (only flat resources)Get group sub-resources the user can perform an action on.
const groupTypes = permissions.getAccessibleGroupSubResources(user, 'site456', 'create');
// Returns: ['schools', 'classes', 'cohorts']Get admin sub-resources the user can perform an action on.
const adminTypes = permissions.getAccessibleAdminSubResources(user, 'site456', 'create');
// Returns: ['research_assistant']Get the user's role for a specific site.
const role = await permissions.getUserRole('user123', 'site456');
// Returns: 'admin' | 'site_admin' | etc.Clear cached data for a specific user.
await permissions.clearUserCache('user123');new CacheService(defaultTtl?: number) // Default: 5 minutesRetrieve cached value.
const value = cache.get('user:123:permissions');Store value in cache with optional TTL.
cache.set('user:123:permissions', permissions, 300000); // 5 minutesRemove specific key or clear entire cache.
cache.delete('user:123:permissions');
cache.clear();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';
}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"
}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
}
}- 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
- 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
npm run build # Compile TypeScript
npm run dev # Watch mode
npm run clean # Remove dist directorynpm test # Run tests in watch mode
npm run test:run # Run tests oncenpm pack # Create tarball for local testingThis 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)
TBD