feat: add flexible capability-based RBAC with per-tenant customization

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>
This commit is contained in:
2026-04-06 15:28:14 -04:00
parent 5fec296569
commit 43b10869f0
55 changed files with 1351 additions and 86 deletions

View File

@@ -0,0 +1,14 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITIES_KEY = 'required_capabilities';
/**
* Decorator to require specific capabilities on an endpoint.
* User must have ALL listed capabilities to access the endpoint.
*
* Usage:
* @RequireCapability('financials.accounts.edit')
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
*/
export const RequireCapability = (...capabilities: string[]) =>
SetMetadata(CAPABILITIES_KEY, capabilities);

View File

@@ -0,0 +1,83 @@
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);
}
}

View File

@@ -0,0 +1,65 @@
/**
* Capability taxonomy for the HOA Financial Platform.
*
* Pattern: {area}.{feature}.{action}
* Actions: view, edit, approve, manage
*
* Add new capabilities here when new features are built.
* The default role matrix in ./default-role-capabilities.ts must also be updated.
*/
export const CAPABILITIES = {
// Dashboard
DASHBOARD_VIEW: 'dashboard.view',
// Financials
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
// Assessments
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
// Board Planning
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
// Board Reference
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
// Transactions
TRANSACTIONS_VIEW: 'transactions.view',
TRANSACTIONS_EDIT: 'transactions.edit',
TRANSACTIONS_APPROVE: 'transactions.approve',
// Reports
REPORTS_VIEW: 'reports.view',
// Settings & Administration
SETTINGS_ORG_VIEW: 'settings.org.view',
SETTINGS_ORG_EDIT: 'settings.org.edit',
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
} as const;
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
/** Set of all valid capability strings, for validation */
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));

View File

@@ -0,0 +1,157 @@
import { CAPABILITIES, Capability } from './capabilities';
const C = CAPABILITIES;
/**
* Default capability sets per role.
*
* These represent sensible defaults for a typical HOA. Tenant admins can
* customize per-role capabilities via permission overrides in org settings.
*
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
*/
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
admin: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
vice_president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
treasurer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
secretary: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
member_at_large: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
manager: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
],
homeowner: [
C.DASHBOARD_VIEW,
C.REPORTS_VIEW,
],
viewer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
],
};

View File

@@ -0,0 +1,5 @@
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
export type { Capability } from './capabilities';
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
export type { PermissionOverrides } from './resolve-permissions';

View File

@@ -0,0 +1,57 @@
import { ALL_CAPABILITIES } from './capabilities';
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export interface PermissionOverrides {
[role: string]: {
grant?: string[];
revoke?: string[];
};
}
/**
* Resolve effective capabilities for a role, applying tenant overrides.
*
* 1. Start with default capabilities for the role
* 2. Add any granted capabilities from overrides
* 3. Remove any revoked capabilities from overrides
*
* Unknown capabilities in grant/revoke are silently ignored (they may
* come from an older version of the overrides).
*/
export function resolveCapabilities(
role: string,
overrides?: PermissionOverrides | null,
): Set<string> {
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
const result = new Set<string>(defaults);
if (overrides && overrides[role]) {
const roleOverride = overrides[role];
if (roleOverride.grant) {
for (const cap of roleOverride.grant) {
if (ALL_CAPABILITIES.has(cap)) {
result.add(cap);
}
}
}
if (roleOverride.revoke) {
for (const cap of roleOverride.revoke) {
result.delete(cap);
}
}
}
return result;
}
/**
* Convenience: resolve to a sorted array (for API responses).
*/
export function resolveCapabilitiesArray(
role: string,
overrides?: PermissionOverrides | null,
): string[] {
return Array.from(resolveCapabilities(role, overrides)).sort();
}