Introduces a capability layer on top of existing roles that controls feature visibility and access. Capabilities follow an area.feature.action taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins can customize via grant/revoke overrides stored in org settings JSONB. Key changes: - Add vice_president role to DB schema - Backend: capability constants, resolution logic, CapabilityGuard (global), @RequireCapability decorator on all 16 tenant controllers - Frontend: permission hooks (useCanEdit, useHasCapability), CapabilityGate component, sidebar filtering by capability, all 17 pages migrated from useIsReadOnly to capability-based checks - New admin UI: /settings/permissions matrix page for per-tenant role customization with grant/revoke delta model - GET /organizations/my-capabilities endpoint for capability refresh - Validation of permissionOverrides in settings updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { DataSource } from 'typeorm';
|
|
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
|
import { resolveCapabilities } from '../permissions';
|
|
|
|
@Injectable()
|
|
export class CapabilityGuard implements CanActivate {
|
|
// Cache org settings (including permissionOverrides) per orgId
|
|
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
|
|
|
constructor(
|
|
private reflector: Reflector,
|
|
private dataSource: DataSource,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
|
|
// No capabilities required — pass through (backward compatible)
|
|
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const user = request.user;
|
|
|
|
// No authenticated user — let other guards handle auth
|
|
if (!user) return true;
|
|
|
|
// Superadmins bypass all capability checks
|
|
if (user.isSuperadmin) return true;
|
|
|
|
const role = user.role;
|
|
const orgId = user.orgId;
|
|
|
|
if (!role || !orgId) return true;
|
|
|
|
// Get org settings (with caching)
|
|
const settings = await this.getOrgSettings(orgId);
|
|
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
|
|
|
// User must have ALL required capabilities
|
|
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
|
if (!hasAll) {
|
|
throw new ForbiddenException(
|
|
'You do not have the required permissions for this action.',
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
|
const cached = this.settingsCache.get(orgId);
|
|
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
|
return cached.settings;
|
|
}
|
|
try {
|
|
const result = await this.dataSource.query(
|
|
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
|
[orgId],
|
|
);
|
|
if (result.length > 0) {
|
|
const settings = result[0].settings || {};
|
|
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
|
return settings;
|
|
}
|
|
} catch {
|
|
// Non-critical — fall through to use defaults only
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Clear cached settings for an org (call after settings update) */
|
|
clearCache(orgId: string) {
|
|
this.settingsCache.delete(orgId);
|
|
}
|
|
}
|