tenantpilot/lib/actions/policySettings.ts
Ahmed Darrazi 41e80b6c0c
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
feat(policy-explorer-v2): implement MVP Phase 1-3
 New Features
- Advanced data table with TanStack Table v8 + Server Actions
- Server-side pagination (10/25/50/100 rows per page)
- Multi-column sorting with visual indicators
- Column management (show/hide, resize) persisted to localStorage
- URL state synchronization for shareable filtered views
- Sticky header with compact/comfortable density modes

📦 Components Added
- PolicyTableV2.tsx - Main table with TanStack integration
- PolicyTableColumns.tsx - 7 column definitions with sorting
- PolicyTablePagination.tsx - Pagination controls
- PolicyTableToolbar.tsx - Density toggle + column visibility menu
- ColumnVisibilityMenu.tsx - Show/hide columns dropdown

🔧 Hooks Added
- usePolicyTable.ts - TanStack Table initialization
- useURLState.ts - URL query param sync with nuqs
- useTablePreferences.ts - localStorage persistence

🎨 Server Actions Updated
- getPolicySettingsV2 - Pagination + sorting + filtering + Zod validation
- exportPolicySettingsCSV - Server-side CSV generation (max 5000 rows)

📚 Documentation Added
- Intune Migration Guide (1400+ lines) - Reverse engineering strategy
- Intune Reference Version tracking
- Tasks completed: 22/62 (Phase 1-3)

 Zero TypeScript compilation errors
 All MVP success criteria met (pagination, sorting, column management)
 Ready for Phase 4-7 (filtering, export, detail view, polish)

Refs: specs/004-policy-explorer-v2/tasks.md
2025-12-10 00:18:05 +01:00

690 lines
21 KiB
TypeScript

'use server';
import { db, policySettings, type PolicySetting } from '@/lib/db';
import { getUserAuth } from '@/lib/auth/utils';
import { eq, ilike, or, desc, asc, and, ne, isNotNull, inArray, count, sql } from 'drizzle-orm';
import { env } from '@/lib/env.mjs';
import { syncQueue } from '@/lib/queue/syncQueue';
import { z } from 'zod';
import type {
GetPolicySettingsParams,
GetPolicySettingsResult,
ExportPolicySettingsParams,
ExportPolicySettingsResult,
PaginationMeta,
PolicySettingRow,
} from '@/lib/types/policy-table';
export interface PolicySettingSearchResult {
id: string;
policyName: string;
policyType: string;
settingName: string;
settingValue: string;
lastSyncedAt: Date;
}
export interface SearchResult {
success: boolean;
data?: PolicySettingSearchResult[];
error?: string;
totalCount?: number;
}
export interface GetSettingResult {
success: boolean;
data?: PolicySetting;
error?: string;
}
export interface RecentSettingsResult {
success: boolean;
data?: PolicySettingSearchResult[];
error?: string;
}
export interface AllSettingsResult {
success: boolean;
data?: PolicySettingSearchResult[];
error?: string;
totalCount?: number;
}
/**
* Search policy settings by keyword across settingName and settingValue
*
* **Security**: This function enforces tenant isolation by:
* 1. Validating user session via getUserAuth()
* 2. Extracting tenantId from session
* 3. Including explicit WHERE tenantId = ? in ALL queries
*
* @param searchTerm - Search query (min 2 characters)
* @param limit - Maximum number of results (default 100, max 200)
* @returns Search results filtered by user's tenant
*/
export async function searchPolicySettings(
searchTerm: string,
limit: number = 100
): Promise<SearchResult> {
try {
const { session } = await getUserAuth();
// T017: Explicit security check - must have authenticated session
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// T017: Explicit security check - must have tenantId in session
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
if (searchTerm.length < 2) {
return { success: false, error: 'Search term too short (min 2 characters)' };
}
// Limit search term length to prevent abuse
const sanitizedSearchTerm = searchTerm.slice(0, 200);
const searchPattern = `%${sanitizedSearchTerm}%`;
// Enforce maximum limit
const safeLimit = Math.min(Math.max(1, limit), 200);
// Explicit WHERE clause filters by tenantId FIRST for security + null filtering
const results = await db
.select({
id: policySettings.id,
policyName: policySettings.policyName,
policyType: policySettings.policyType,
settingName: policySettings.settingName,
settingValue: policySettings.settingValue,
lastSyncedAt: policySettings.lastSyncedAt,
})
.from(policySettings)
.where(
and(
eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation
ne(policySettings.settingValue, 'null'), // Filter out string "null"
ne(policySettings.settingValue, ''), // Filter out empty strings
isNotNull(policySettings.settingValue), // Filter out NULL values
or(
ilike(policySettings.settingName, searchPattern),
ilike(policySettings.settingValue, searchPattern)
)
)
)
.orderBy(policySettings.settingName)
.limit(safeLimit);
return {
success: true,
data: results,
totalCount: results.length,
};
} catch (error) {
console.error('Search failed:', error);
return { success: false, error: 'Search failed' };
}
}
/**
* Get a single policy setting by ID
*
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter
*
* @param id - Policy setting ID
* @returns Policy setting if found and belongs to user's tenant
*/
export async function getPolicySettingById(
id: string
): Promise<GetSettingResult> {
try {
const { session } = await getUserAuth();
// T017: Explicit security check
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// T017: Explicit security check
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
// T017: Query filtered by tenantId FIRST for security
const [result] = await db
.select()
.from(policySettings)
.where(
and(
eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation
eq(policySettings.id, id)
)
)
.limit(1);
if (!result) {
return { success: false, error: 'Policy setting not found' };
}
return {
success: true,
data: result,
};
} catch (error) {
console.error('Get policy setting failed:', error);
return { success: false, error: 'Failed to fetch policy setting' };
}
}
/**
* Get recent policy settings sorted by last sync date
*
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter
*
* @param limit - Maximum number of results (1-100, default 50)
* @returns Recent policy settings for user's tenant
*/
export async function getRecentPolicySettings(
limit: number = 50
): Promise<RecentSettingsResult> {
try {
const { session } = await getUserAuth();
// T017: Explicit security check
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// T017: Explicit security check
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
// Clamp limit between 1 and 100
const safeLimit = Math.max(1, Math.min(100, limit));
// T017: Query filtered by tenantId for security
const results = await db
.select({
id: policySettings.id,
policyName: policySettings.policyName,
policyType: policySettings.policyType,
settingName: policySettings.settingName,
settingValue: policySettings.settingValue,
lastSyncedAt: policySettings.lastSyncedAt,
})
.from(policySettings)
.where(eq(policySettings.tenantId, tenantId)) // CRITICAL: Tenant isolation
.orderBy(desc(policySettings.lastSyncedAt))
.limit(safeLimit);
return {
success: true,
data: results,
};
} catch (error) {
console.error('Get recent settings failed:', error);
return { success: false, error: 'Failed to fetch recent settings' };
}
}
/**
* Get all policy settings for the current user's tenant
*
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter
*
* @returns All policy settings for user's tenant
*/
export async function getAllPolicySettings(): Promise<AllSettingsResult> {
try {
const { session } = await getUserAuth();
// T017: Explicit security check
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// T017: Explicit security check
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
// T017: Query filtered by tenantId for security
const results = await db
.select({
id: policySettings.id,
policyName: policySettings.policyName,
policyType: policySettings.policyType,
settingName: policySettings.settingName,
settingValue: policySettings.settingValue,
lastSyncedAt: policySettings.lastSyncedAt,
})
.from(policySettings)
.where(eq(policySettings.tenantId, tenantId)) // CRITICAL: Tenant isolation
.orderBy(desc(policySettings.lastSyncedAt));
return {
success: true,
data: results,
totalCount: results.length,
};
} catch (error) {
console.error('Get all settings failed:', error);
return { success: false, error: 'Failed to fetch settings' };
}
}
/**
* TEMPORARY: Seed test data for the current user's tenant
* This is a development helper to populate realistic policy settings
*/
export async function seedMyTenantData(): Promise<{
success: boolean;
error?: string;
message?: string;
}> {
try {
const { session } = await getUserAuth();
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant ID not found in session' };
}
// Create 5 realistic policy settings for the user's tenant
const seedData = [
{
tenantId,
policyName: 'Windows 10 Security Baseline',
policyType: 'deviceConfiguration' as const,
settingName: 'USB.BlockExternalDevices',
settingValue: 'enabled',
graphPolicyId: `seed-${tenantId}-policy-001`,
},
{
tenantId,
policyName: 'BitLocker Compliance Policy',
policyType: 'compliancePolicy' as const,
settingName: 'BitLocker.RequireEncryption',
settingValue: 'true',
graphPolicyId: `seed-${tenantId}-policy-002`,
},
{
tenantId,
policyName: 'Camera and Microphone Restrictions',
policyType: 'deviceConfiguration' as const,
settingName: 'Camera.DisableCamera',
settingValue: 'false',
graphPolicyId: `seed-${tenantId}-policy-003`,
},
{
tenantId,
policyName: 'Windows Defender Configuration',
policyType: 'endpointSecurity' as const,
settingName: 'Defender.EnableRealTimeProtection',
settingValue: 'enabled',
graphPolicyId: `seed-${tenantId}-policy-004`,
},
{
tenantId,
policyName: 'Windows Update for Business',
policyType: 'windowsUpdateForBusiness' as const,
settingName: 'WindowsUpdate.DeferFeatureUpdatesPeriodInDays',
settingValue: '30',
graphPolicyId: `seed-${tenantId}-policy-005`,
},
];
// Insert all seed data
for (const data of seedData) {
await db.insert(policySettings).values({
...data,
lastSyncedAt: new Date(),
});
}
return {
success: true,
message: `Successfully seeded 5 policy settings for tenant ${tenantId}`,
};
} catch (error) {
console.error('Seed data failed:', error);
return { success: false, error: 'Failed to seed data' };
}
}
/**
* Trigger manual policy sync via BullMQ worker
*
* **Security**: This function enforces tenant isolation by:
* 1. Validating user session via getUserAuth()
* 2. Extracting tenantId from session
* 3. Enqueuing a job with only the authenticated user's tenantId
*
* @returns Success/error result with job ID
*/
export async function triggerPolicySync(): Promise<{ success: boolean; message?: string; error?: string; jobId?: string }> {
try {
const { session } = await getUserAuth();
if (!session?.user) {
return { success: false, error: 'Not authenticated' };
}
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'No tenant ID found in session' };
}
// Enqueue sync job to BullMQ
const job = await syncQueue.add('sync-tenant', {
tenantId,
source: 'manual_trigger',
triggeredAt: new Date().toISOString(),
triggeredBy: session.user.email || session.user.id,
});
return {
success: true,
message: `Policy sync queued successfully (Job #${job.id})`,
jobId: job.id,
};
} catch (error) {
console.error('Failed to trigger policy sync:', error);
return {
success: false,
error: 'Failed to queue sync job. Please try again later.',
};
}
}
/**
* ========================================
* POLICY EXPLORER V2 - Advanced Data Table Server Actions
* ========================================
*/
/**
* Zod schema for getPolicySettings input validation
*/
const GetPolicySettingsSchema = z.object({
page: z.number().int().min(0).default(0),
pageSize: z.union([z.literal(10), z.literal(25), z.literal(50), z.literal(100)]).default(50),
sortBy: z.enum(['settingName', 'policyName', 'policyType', 'lastSyncedAt']).optional(),
sortDir: z.enum(['asc', 'desc']).default('asc'),
policyTypes: z.array(z.string()).optional(),
searchQuery: z.string().optional(),
});
/**
* Get policy settings with pagination, sorting, and filtering
*
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter
* **Performance**: Uses composite index on (tenantId, policyType, settingName)
*
* @param params - Pagination, sorting, and filtering parameters
* @returns Paginated policy settings with metadata
*/
export async function getPolicySettingsV2(
params: GetPolicySettingsParams
): Promise<GetPolicySettingsResult> {
try {
const { session } = await getUserAuth();
// Security check: Require authenticated session
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// Security check: Require tenantId
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
// Validate input parameters
const validatedParams = GetPolicySettingsSchema.parse(params);
const { page, pageSize, sortBy, sortDir, policyTypes, searchQuery } = validatedParams;
// Build WHERE clause with tenant isolation
const whereConditions = [
eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation
ne(policySettings.settingValue, 'null'), // Filter out string "null"
ne(policySettings.settingValue, ''), // Filter out empty strings
isNotNull(policySettings.settingValue), // Filter out NULL values
];
// Add policy type filter if provided
if (policyTypes && policyTypes.length > 0) {
whereConditions.push(inArray(policySettings.policyType, policyTypes));
}
// Add search query filter if provided
if (searchQuery && searchQuery.trim().length >= 2) {
const searchPattern = `%${searchQuery.trim().slice(0, 200)}%`;
whereConditions.push(
or(
ilike(policySettings.settingName, searchPattern),
ilike(policySettings.settingValue, searchPattern),
ilike(policySettings.policyName, searchPattern)
)!
);
}
// Build ORDER BY clause
const orderByColumn = sortBy
? policySettings[sortBy as keyof typeof policySettings]
: policySettings.settingName;
const orderByDirection = sortDir === 'desc' ? desc : asc;
// Execute count query for pagination metadata
const [{ totalCount }] = await db
.select({ totalCount: count() })
.from(policySettings)
.where(and(...whereConditions));
// Calculate pagination metadata
const pageCount = Math.ceil(totalCount / pageSize);
const hasNextPage = page < pageCount - 1;
const hasPreviousPage = page > 0;
// Execute data query with pagination
const data = await db
.select({
id: policySettings.id,
tenantId: policySettings.tenantId,
policyName: policySettings.policyName,
policyType: policySettings.policyType,
settingName: policySettings.settingName,
settingValue: policySettings.settingValue,
graphPolicyId: policySettings.graphPolicyId,
lastSyncedAt: policySettings.lastSyncedAt,
createdAt: policySettings.createdAt,
})
.from(policySettings)
.where(and(...whereConditions))
.orderBy(orderByDirection(orderByColumn as any))
.limit(pageSize)
.offset(page * pageSize);
const meta: PaginationMeta = {
totalCount,
pageCount,
currentPage: page,
pageSize,
hasNextPage,
hasPreviousPage,
};
return {
success: true,
data: data as PolicySettingRow[],
meta,
};
} catch (error) {
console.error('getPolicySettingsV2 failed:', error);
if (error instanceof z.ZodError) {
return { success: false, error: 'Invalid parameters: ' + error.issues.map((e) => e.message).join(', ') };
}
return { success: false, error: 'Failed to fetch policy settings' };
}
}
/**
* Zod schema for exportPolicySettingsCSV input validation
*/
const ExportPolicySettingsSchema = z.object({
policyTypes: z.array(z.string()).optional(),
searchQuery: z.string().optional(),
sortBy: z.enum(['settingName', 'policyName', 'policyType', 'lastSyncedAt']).optional(),
sortDir: z.enum(['asc', 'desc']).default('asc'),
maxRows: z.number().int().min(1).max(5000).default(5000),
});
/**
* Helper function to escape CSV values
* Handles commas, quotes, and newlines according to RFC 4180
*/
function escapeCsvValue(value: string): string {
// If value contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/**
* Export policy settings as CSV (server-side, max 5000 rows)
*
* **Security**: Enforces tenant isolation with explicit WHERE tenantId filter
* **Performance**: Limits export to 5000 rows to prevent memory issues
*
* @param params - Filtering and sorting parameters
* @returns CSV content as string with filename
*/
export async function exportPolicySettingsCSV(
params: ExportPolicySettingsParams = {}
): Promise<ExportPolicySettingsResult> {
try {
const { session } = await getUserAuth();
// Security check: Require authenticated session
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// Security check: Require tenantId
const tenantId = session.user.tenantId;
if (!tenantId) {
return { success: false, error: 'Tenant not found' };
}
// Validate input parameters
const validatedParams = ExportPolicySettingsSchema.parse(params);
const { policyTypes, searchQuery, sortBy, sortDir, maxRows } = validatedParams;
// Build WHERE clause with tenant isolation
const whereConditions = [
eq(policySettings.tenantId, tenantId), // CRITICAL: Tenant isolation
ne(policySettings.settingValue, 'null'),
ne(policySettings.settingValue, ''),
isNotNull(policySettings.settingValue),
];
// Add policy type filter if provided
if (policyTypes && policyTypes.length > 0) {
whereConditions.push(inArray(policySettings.policyType, policyTypes));
}
// Add search query filter if provided
if (searchQuery && searchQuery.trim().length >= 2) {
const searchPattern = `%${searchQuery.trim().slice(0, 200)}%`;
whereConditions.push(
or(
ilike(policySettings.settingName, searchPattern),
ilike(policySettings.settingValue, searchPattern),
ilike(policySettings.policyName, searchPattern)
)!
);
}
// Build ORDER BY clause
const orderByColumn = sortBy
? policySettings[sortBy as keyof typeof policySettings]
: policySettings.settingName;
const orderByDirection = sortDir === 'desc' ? desc : asc;
// Fetch data (limited to maxRows)
const data = await db
.select({
id: policySettings.id,
policyName: policySettings.policyName,
policyType: policySettings.policyType,
settingName: policySettings.settingName,
settingValue: policySettings.settingValue,
graphPolicyId: policySettings.graphPolicyId,
lastSyncedAt: policySettings.lastSyncedAt,
})
.from(policySettings)
.where(and(...whereConditions))
.orderBy(orderByDirection(orderByColumn as any))
.limit(maxRows);
// Generate CSV content
// Add UTF-8 BOM for Excel compatibility
const BOM = '\uFEFF';
// CSV header
const headers = [
'Policy Name',
'Policy Type',
'Setting Name',
'Setting Value',
'Graph Policy ID',
'Last Synced At',
];
const headerRow = headers.map(h => escapeCsvValue(h)).join(',');
// CSV rows
const dataRows = data.map(row => {
return [
escapeCsvValue(row.policyName),
escapeCsvValue(row.policyType),
escapeCsvValue(row.settingName),
escapeCsvValue(row.settingValue),
escapeCsvValue(row.graphPolicyId),
escapeCsvValue(row.lastSyncedAt.toISOString()),
].join(',');
});
const csv = BOM + headerRow + '\n' + dataRows.join('\n');
// Generate filename with timestamp
const timestamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const filename = `policy-settings-${timestamp}.csv`;
return {
success: true,
csv,
filename,
rowCount: data.length,
};
} catch (error) {
console.error('exportPolicySettingsCSV failed:', error);
if (error instanceof z.ZodError) {
return { success: false, error: 'Invalid parameters: ' + error.issues.map((e) => e.message).join(', ') };
}
return { success: false, error: 'Failed to export policy settings' };
}
}