Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions backend/src/__tests__/orgAuditService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Unit tests for OrgAuditService
*
* Uses a mock pg Pool so no real database connection is required.
*/

import { OrgAuditService } from '../services/orgAuditService.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makePool(queryResult: { rows: unknown[]; rowCount?: number }) {
return {
query: jest.fn().mockResolvedValue(queryResult),
} as unknown as import('pg').Pool;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('OrgAuditService', () => {
describe('log()', () => {
it('inserts a row with the correct parameters', async () => {
const mockRow = { id: '1', organization_id: 42, change_type: 'setting_upserted' };
const mockPool = makePool({ rows: [mockRow] });
const service = new OrgAuditService(mockPool);

const result = await service.log({
organizationId: 42,
changeType: 'setting_upserted',
configKey: 'payment_settings',
oldValue: { currency: 'USD' },
newValue: { currency: 'EUR' },
actorId: 7,
actorEmail: 'admin@example.com',
actorIp: '127.0.0.1',
});

expect(mockPool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (mockPool.query as jest.Mock).mock.calls[0];
expect(sql).toContain('INSERT INTO org_audit_log');
expect(params[0]).toBe(42); // organizationId
expect(params[1]).toBe('setting_upserted'); // changeType
expect(params[2]).toBe('payment_settings'); // configKey
expect(JSON.parse(params[3])).toEqual({ currency: 'USD' }); // oldValue
expect(JSON.parse(params[4])).toEqual({ currency: 'EUR' }); // newValue
expect(params[5]).toBe(7); // actorId
expect(params[6]).toBe('admin@example.com'); // actorEmail
expect(result).toEqual(mockRow);
});

it('returns null and does not throw when the insert fails', async () => {
const mockPool = {
query: jest.fn().mockRejectedValue(new Error('DB error')),
} as unknown as import('pg').Pool;
const service = new OrgAuditService(mockPool);

const result = await service.log({ organizationId: 1, changeType: 'name_updated' });
expect(result).toBeNull();
});

it('stores null config_key when not provided', async () => {
const mockPool = makePool({ rows: [{ id: '2' }] });
const service = new OrgAuditService(mockPool);

await service.log({ organizationId: 1, changeType: 'name_updated', newValue: 'Acme' });

const params = (mockPool.query as jest.Mock).mock.calls[0][1];
expect(params[2]).toBeNull(); // configKey
});
});

describe('list()', () => {
it('returns rows and total count', async () => {
const mockRows = [
{ id: '1', organization_id: 10, change_type: 'setting_upserted', created_at: '2025-01-01' },
];
const mockPool = {
query: jest
.fn()
.mockResolvedValueOnce({ rows: mockRows }) // data query
.mockResolvedValueOnce({ rows: [{ count: '1' }] }), // count query
} as unknown as import('pg').Pool;
const service = new OrgAuditService(mockPool);

const { rows, total } = await service.list(10);
expect(rows).toEqual(mockRows);
expect(total).toBe(1);
});

it('caps limit at 200', async () => {
const mockPool = {
query: jest
.fn()
.mockResolvedValue({ rows: [] }),
} as unknown as import('pg').Pool;
const service = new OrgAuditService(mockPool);

await service.list(1, { limit: 9999 });
const params = (mockPool.query as jest.Mock).mock.calls[0][1];
expect(params[1]).toBe(200);
});
});
});
33 changes: 33 additions & 0 deletions backend/src/controllers/tenantConfigController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import tenantConfigService from '../services/tenantConfigService.js';
import { orgAuditService } from '../services/orgAuditService.js';

const upsertSchema = z.object({
configKey: z.string().min(1).max(100),
Expand Down Expand Up @@ -46,12 +47,29 @@ export class TenantConfigController {
return res.status(403).json({ error: 'User is not associated with an organization' });
}
const { configKey, configValue, description } = upsertSchema.parse(req.body);

// Capture old value for audit diff
const oldValue = await tenantConfigService.getConfig(organizationId, configKey);

const updated = await tenantConfigService.setConfig(
organizationId,
configKey,
configValue,
description
);

// Fire-and-forget audit log (errors must not break the response)
void orgAuditService.log({
organizationId,
changeType: 'setting_upserted',
configKey,
oldValue: oldValue ?? undefined,
newValue: configValue,
actorId: req.user?.id,
actorEmail: req.user?.email,
actorIp: req.ip,
});

return res.status(200).json({ success: true, data: updated });
} catch (error) {
if (error instanceof z.ZodError) {
Expand All @@ -69,8 +87,23 @@ export class TenantConfigController {
return res.status(403).json({ error: 'User is not associated with an organization' });
}
const configKey = req.params.configKey as string;

// Capture old value for audit diff before deleting
const oldValue = await tenantConfigService.getConfig(organizationId, configKey);

const deleted = await tenantConfigService.deleteConfig(organizationId, configKey);
if (!deleted) return res.status(404).json({ error: 'Configuration not found' });

void orgAuditService.log({
organizationId,
changeType: 'setting_deleted',
configKey,
oldValue: oldValue ?? undefined,
actorId: req.user?.id,
actorEmail: req.user?.email,
actorIp: req.ip,
});

return res.status(204).send();
} catch (error) {
console.error('delete tenant config error:', error);
Expand Down
50 changes: 50 additions & 0 deletions backend/src/db/migrations/027_create_org_audit_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- =============================================================================
-- Migration 027: Organization Audit Log
-- Purpose : Dedicated, append-only table that records every change made to an
-- organization's name, settings (tenant_configurations), or issuer
-- account. Kept separate from the generic audit_logs table so that
-- compliance queries targeting org-level data stay fast and clear.
-- =============================================================================

CREATE TABLE IF NOT EXISTS org_audit_log (
id BIGSERIAL PRIMARY KEY,

-- Which organization was changed
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

-- What kind of change occurred
-- Allowed values: 'name_updated' | 'setting_upserted' | 'setting_deleted'
-- | 'issuer_updated' | 'org_created' | 'org_deleted'
change_type VARCHAR(50) NOT NULL,

-- The config key that was changed (NULL for name / issuer changes)
config_key VARCHAR(100),

-- JSON snapshots of the value before and after the change
old_value JSONB,
new_value JSONB,

-- Who made the change
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
actor_email VARCHAR(255),
actor_ip INET,

-- When the change happened (immutable)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Enforce append-only semantics: no UPDATE or DELETE on existing rows
CREATE RULE org_audit_log_no_update AS ON UPDATE TO org_audit_log DO INSTEAD NOTHING;
CREATE RULE org_audit_log_no_delete AS ON DELETE TO org_audit_log DO INSTEAD NOTHING;

-- Index for the most common query patterns
CREATE INDEX IF NOT EXISTS idx_org_audit_log_org ON org_audit_log (organization_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_org_audit_log_actor ON org_audit_log (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_org_audit_log_type ON org_audit_log (change_type, created_at DESC);

COMMENT ON TABLE org_audit_log IS
'Append-only log of every change made to an organization''s name, settings, or issuer account.';
COMMENT ON COLUMN org_audit_log.change_type IS
'Classifies the kind of change: name_updated, setting_upserted, setting_deleted, issuer_updated, org_created, org_deleted.';
COMMENT ON COLUMN org_audit_log.config_key IS
'The tenant_configurations key that was changed; NULL for name/issuer changes.';
38 changes: 38 additions & 0 deletions backend/src/routes/orgAuditRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Router, Request, Response } from 'express';
import authenticateJWT from '../middlewares/auth.js';
import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js';
import { orgAuditService } from '../services/orgAuditService.js';

const router = Router();

router.use(authenticateJWT);
router.use(authorizeRoles('EMPLOYER'));
router.use(isolateOrganization);

/**
* GET /api/org-audit
* Returns paginated organization audit log entries for the authenticated org.
*
* Query params:
* limit – max rows per page (default 50, max 200)
* offset – rows to skip (default 0)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const organizationId = req.user?.organizationId;
if (!organizationId) {
return res.status(403).json({ error: 'User is not associated with an organization' });
}

const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
const offset = Math.max(parseInt(String(req.query.offset ?? '0'), 10) || 0, 0);

const { rows, total } = await orgAuditService.list(organizationId, { limit, offset });
return res.status(200).json({ success: true, data: rows, total, limit, offset });
} catch (err) {
console.error('org-audit list error:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
});

export default router;
112 changes: 112 additions & 0 deletions backend/src/services/orgAuditService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* orgAuditService
*
* Writes append-only records to the org_audit_log table whenever an
* organization's name, tenant settings, or issuer account changes.
*
* All methods are fire-and-forget safe: errors are logged but never
* re-thrown so that a logging failure cannot break the main request path.
*/

import { Pool } from 'pg';
import { pool } from '../config/database.js';

export type OrgChangeType =
| 'name_updated'
| 'setting_upserted'
| 'setting_deleted'
| 'issuer_updated'
| 'org_created'
| 'org_deleted';

export interface OrgAuditEntry {
organizationId: number;
changeType: OrgChangeType;
configKey?: string;
oldValue?: unknown;
newValue?: unknown;
actorId?: number;
actorEmail?: string;
actorIp?: string;
}

export interface OrgAuditRow {
id: string;
organization_id: number;
change_type: string;
config_key: string | null;
old_value: unknown;
new_value: unknown;
actor_id: number | null;
actor_email: string | null;
actor_ip: string | null;
created_at: string;
}

export class OrgAuditService {
constructor(private readonly db: Pool = pool) {}

/**
* Append a single entry to the org_audit_log table.
* Returns the inserted row, or null if the insert failed.
*/
async log(entry: OrgAuditEntry): Promise<OrgAuditRow | null> {
const query = `
INSERT INTO org_audit_log
(organization_id, change_type, config_key, old_value, new_value,
actor_id, actor_email, actor_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::inet)
RETURNING *
`;

try {
const result = await this.db.query<OrgAuditRow>(query, [
entry.organizationId,
entry.changeType,
entry.configKey ?? null,
entry.oldValue !== undefined ? JSON.stringify(entry.oldValue) : null,
entry.newValue !== undefined ? JSON.stringify(entry.newValue) : null,
entry.actorId ?? null,
entry.actorEmail ?? null,
entry.actorIp ?? null,
]);
return result.rows[0] ?? null;
} catch (err) {
console.error('[OrgAuditService] Failed to write audit log entry:', err);
return null;
}
}

/**
* Retrieve paginated audit log entries for an organization.
* Results are ordered newest-first.
*/
async list(
organizationId: number,
options: { limit?: number; offset?: number } = {}
): Promise<{ rows: OrgAuditRow[]; total: number }> {
const limit = Math.min(options.limit ?? 50, 200);
const offset = options.offset ?? 0;

const [dataResult, countResult] = await Promise.all([
this.db.query<OrgAuditRow>(
`SELECT * FROM org_audit_log
WHERE organization_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[organizationId, limit, offset]
),
this.db.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM org_audit_log WHERE organization_id = $1`,
[organizationId]
),
]);

return {
rows: dataResult.rows,
total: parseInt(countResult.rows[0]?.count ?? '0', 10),
};
}
}

export const orgAuditService = new OrgAuditService();
2 changes: 1 addition & 1 deletion frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const AppLayout: React.FC = () => {
>
{/* Header */}
<header
className="fixed top-0 left-0 right-0 z-50 h-(--header-h) items-center px-16 flex justify-between backdrop-blur-[20px] backdrop-saturate-180 border-b"
className="fixed top-0 left-0 right-0 z-50 h-(--header-h) items-center px-4 sm:px-8 lg:px-16 flex justify-between backdrop-blur-[20px] backdrop-saturate-180 border-b"
style={{
background: 'color-mix(in srgb, var(--bg) 85%, transparent)',
borderColor: 'var(--border-hi)',
Expand Down
Loading
Loading