Skip to content

Commit 10177fd

Browse files
authored
Merge pull request #571 from playground-ogazboiz/feature/355-org-audit-log
feat(#355): add detailed audit log for organization changes
2 parents ff3072c + 2ef1493 commit 10177fd

5 files changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Unit tests for OrgAuditService
3+
*
4+
* Uses a mock pg Pool so no real database connection is required.
5+
*/
6+
7+
import { OrgAuditService } from '../services/orgAuditService.js';
8+
9+
// ---------------------------------------------------------------------------
10+
// Helpers
11+
// ---------------------------------------------------------------------------
12+
13+
function makePool(queryResult: { rows: unknown[]; rowCount?: number }) {
14+
return {
15+
query: jest.fn().mockResolvedValue(queryResult),
16+
} as unknown as import('pg').Pool;
17+
}
18+
19+
// ---------------------------------------------------------------------------
20+
// Tests
21+
// ---------------------------------------------------------------------------
22+
23+
describe('OrgAuditService', () => {
24+
describe('log()', () => {
25+
it('inserts a row with the correct parameters', async () => {
26+
const mockRow = { id: '1', organization_id: 42, change_type: 'setting_upserted' };
27+
const mockPool = makePool({ rows: [mockRow] });
28+
const service = new OrgAuditService(mockPool);
29+
30+
const result = await service.log({
31+
organizationId: 42,
32+
changeType: 'setting_upserted',
33+
configKey: 'payment_settings',
34+
oldValue: { currency: 'USD' },
35+
newValue: { currency: 'EUR' },
36+
actorId: 7,
37+
actorEmail: 'admin@example.com',
38+
actorIp: '127.0.0.1',
39+
});
40+
41+
expect(mockPool.query).toHaveBeenCalledTimes(1);
42+
const [sql, params] = (mockPool.query as jest.Mock).mock.calls[0];
43+
expect(sql).toContain('INSERT INTO org_audit_log');
44+
expect(params[0]).toBe(42); // organizationId
45+
expect(params[1]).toBe('setting_upserted'); // changeType
46+
expect(params[2]).toBe('payment_settings'); // configKey
47+
expect(JSON.parse(params[3])).toEqual({ currency: 'USD' }); // oldValue
48+
expect(JSON.parse(params[4])).toEqual({ currency: 'EUR' }); // newValue
49+
expect(params[5]).toBe(7); // actorId
50+
expect(params[6]).toBe('admin@example.com'); // actorEmail
51+
expect(result).toEqual(mockRow);
52+
});
53+
54+
it('returns null and does not throw when the insert fails', async () => {
55+
const mockPool = {
56+
query: jest.fn().mockRejectedValue(new Error('DB error')),
57+
} as unknown as import('pg').Pool;
58+
const service = new OrgAuditService(mockPool);
59+
60+
const result = await service.log({ organizationId: 1, changeType: 'name_updated' });
61+
expect(result).toBeNull();
62+
});
63+
64+
it('stores null config_key when not provided', async () => {
65+
const mockPool = makePool({ rows: [{ id: '2' }] });
66+
const service = new OrgAuditService(mockPool);
67+
68+
await service.log({ organizationId: 1, changeType: 'name_updated', newValue: 'Acme' });
69+
70+
const params = (mockPool.query as jest.Mock).mock.calls[0][1];
71+
expect(params[2]).toBeNull(); // configKey
72+
});
73+
});
74+
75+
describe('list()', () => {
76+
it('returns rows and total count', async () => {
77+
const mockRows = [
78+
{ id: '1', organization_id: 10, change_type: 'setting_upserted', created_at: '2025-01-01' },
79+
];
80+
const mockPool = {
81+
query: jest
82+
.fn()
83+
.mockResolvedValueOnce({ rows: mockRows }) // data query
84+
.mockResolvedValueOnce({ rows: [{ count: '1' }] }), // count query
85+
} as unknown as import('pg').Pool;
86+
const service = new OrgAuditService(mockPool);
87+
88+
const { rows, total } = await service.list(10);
89+
expect(rows).toEqual(mockRows);
90+
expect(total).toBe(1);
91+
});
92+
93+
it('caps limit at 200', async () => {
94+
const mockPool = {
95+
query: jest
96+
.fn()
97+
.mockResolvedValue({ rows: [] }),
98+
} as unknown as import('pg').Pool;
99+
const service = new OrgAuditService(mockPool);
100+
101+
await service.list(1, { limit: 9999 });
102+
const params = (mockPool.query as jest.Mock).mock.calls[0][1];
103+
expect(params[1]).toBe(200);
104+
});
105+
});
106+
});

backend/src/controllers/tenantConfigController.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Request, Response } from 'express';
22
import { z } from 'zod';
33
import tenantConfigService from '../services/tenantConfigService.js';
4+
import { orgAuditService } from '../services/orgAuditService.js';
45

56
const upsertSchema = z.object({
67
configKey: z.string().min(1).max(100),
@@ -46,12 +47,29 @@ export class TenantConfigController {
4647
return res.status(403).json({ error: 'User is not associated with an organization' });
4748
}
4849
const { configKey, configValue, description } = upsertSchema.parse(req.body);
50+
51+
// Capture old value for audit diff
52+
const oldValue = await tenantConfigService.getConfig(organizationId, configKey);
53+
4954
const updated = await tenantConfigService.setConfig(
5055
organizationId,
5156
configKey,
5257
configValue,
5358
description
5459
);
60+
61+
// Fire-and-forget audit log (errors must not break the response)
62+
void orgAuditService.log({
63+
organizationId,
64+
changeType: 'setting_upserted',
65+
configKey,
66+
oldValue: oldValue ?? undefined,
67+
newValue: configValue,
68+
actorId: req.user?.id,
69+
actorEmail: req.user?.email,
70+
actorIp: req.ip,
71+
});
72+
5573
return res.status(200).json({ success: true, data: updated });
5674
} catch (error) {
5775
if (error instanceof z.ZodError) {
@@ -69,8 +87,23 @@ export class TenantConfigController {
6987
return res.status(403).json({ error: 'User is not associated with an organization' });
7088
}
7189
const configKey = req.params.configKey as string;
90+
91+
// Capture old value for audit diff before deleting
92+
const oldValue = await tenantConfigService.getConfig(organizationId, configKey);
93+
7294
const deleted = await tenantConfigService.deleteConfig(organizationId, configKey);
7395
if (!deleted) return res.status(404).json({ error: 'Configuration not found' });
96+
97+
void orgAuditService.log({
98+
organizationId,
99+
changeType: 'setting_deleted',
100+
configKey,
101+
oldValue: oldValue ?? undefined,
102+
actorId: req.user?.id,
103+
actorEmail: req.user?.email,
104+
actorIp: req.ip,
105+
});
106+
74107
return res.status(204).send();
75108
} catch (error) {
76109
console.error('delete tenant config error:', error);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
-- =============================================================================
2+
-- Migration 027: Organization Audit Log
3+
-- Purpose : Dedicated, append-only table that records every change made to an
4+
-- organization's name, settings (tenant_configurations), or issuer
5+
-- account. Kept separate from the generic audit_logs table so that
6+
-- compliance queries targeting org-level data stay fast and clear.
7+
-- =============================================================================
8+
9+
CREATE TABLE IF NOT EXISTS org_audit_log (
10+
id BIGSERIAL PRIMARY KEY,
11+
12+
-- Which organization was changed
13+
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
14+
15+
-- What kind of change occurred
16+
-- Allowed values: 'name_updated' | 'setting_upserted' | 'setting_deleted'
17+
-- | 'issuer_updated' | 'org_created' | 'org_deleted'
18+
change_type VARCHAR(50) NOT NULL,
19+
20+
-- The config key that was changed (NULL for name / issuer changes)
21+
config_key VARCHAR(100),
22+
23+
-- JSON snapshots of the value before and after the change
24+
old_value JSONB,
25+
new_value JSONB,
26+
27+
-- Who made the change
28+
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
29+
actor_email VARCHAR(255),
30+
actor_ip INET,
31+
32+
-- When the change happened (immutable)
33+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
34+
);
35+
36+
-- Enforce append-only semantics: no UPDATE or DELETE on existing rows
37+
CREATE RULE org_audit_log_no_update AS ON UPDATE TO org_audit_log DO INSTEAD NOTHING;
38+
CREATE RULE org_audit_log_no_delete AS ON DELETE TO org_audit_log DO INSTEAD NOTHING;
39+
40+
-- Index for the most common query patterns
41+
CREATE INDEX IF NOT EXISTS idx_org_audit_log_org ON org_audit_log (organization_id, created_at DESC);
42+
CREATE INDEX IF NOT EXISTS idx_org_audit_log_actor ON org_audit_log (actor_id, created_at DESC);
43+
CREATE INDEX IF NOT EXISTS idx_org_audit_log_type ON org_audit_log (change_type, created_at DESC);
44+
45+
COMMENT ON TABLE org_audit_log IS
46+
'Append-only log of every change made to an organization''s name, settings, or issuer account.';
47+
COMMENT ON COLUMN org_audit_log.change_type IS
48+
'Classifies the kind of change: name_updated, setting_upserted, setting_deleted, issuer_updated, org_created, org_deleted.';
49+
COMMENT ON COLUMN org_audit_log.config_key IS
50+
'The tenant_configurations key that was changed; NULL for name/issuer changes.';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Router, Request, Response } from 'express';
2+
import authenticateJWT from '../middlewares/auth.js';
3+
import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js';
4+
import { orgAuditService } from '../services/orgAuditService.js';
5+
6+
const router = Router();
7+
8+
router.use(authenticateJWT);
9+
router.use(authorizeRoles('EMPLOYER'));
10+
router.use(isolateOrganization);
11+
12+
/**
13+
* GET /api/org-audit
14+
* Returns paginated organization audit log entries for the authenticated org.
15+
*
16+
* Query params:
17+
* limit – max rows per page (default 50, max 200)
18+
* offset – rows to skip (default 0)
19+
*/
20+
router.get('/', async (req: Request, res: Response) => {
21+
try {
22+
const organizationId = req.user?.organizationId;
23+
if (!organizationId) {
24+
return res.status(403).json({ error: 'User is not associated with an organization' });
25+
}
26+
27+
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
28+
const offset = Math.max(parseInt(String(req.query.offset ?? '0'), 10) || 0, 0);
29+
30+
const { rows, total } = await orgAuditService.list(organizationId, { limit, offset });
31+
return res.status(200).json({ success: true, data: rows, total, limit, offset });
32+
} catch (err) {
33+
console.error('org-audit list error:', err);
34+
return res.status(500).json({ error: 'Internal Server Error' });
35+
}
36+
});
37+
38+
export default router;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* orgAuditService
3+
*
4+
* Writes append-only records to the org_audit_log table whenever an
5+
* organization's name, tenant settings, or issuer account changes.
6+
*
7+
* All methods are fire-and-forget safe: errors are logged but never
8+
* re-thrown so that a logging failure cannot break the main request path.
9+
*/
10+
11+
import { Pool } from 'pg';
12+
import { pool } from '../config/database.js';
13+
14+
export type OrgChangeType =
15+
| 'name_updated'
16+
| 'setting_upserted'
17+
| 'setting_deleted'
18+
| 'issuer_updated'
19+
| 'org_created'
20+
| 'org_deleted';
21+
22+
export interface OrgAuditEntry {
23+
organizationId: number;
24+
changeType: OrgChangeType;
25+
configKey?: string;
26+
oldValue?: unknown;
27+
newValue?: unknown;
28+
actorId?: number;
29+
actorEmail?: string;
30+
actorIp?: string;
31+
}
32+
33+
export interface OrgAuditRow {
34+
id: string;
35+
organization_id: number;
36+
change_type: string;
37+
config_key: string | null;
38+
old_value: unknown;
39+
new_value: unknown;
40+
actor_id: number | null;
41+
actor_email: string | null;
42+
actor_ip: string | null;
43+
created_at: string;
44+
}
45+
46+
export class OrgAuditService {
47+
constructor(private readonly db: Pool = pool) {}
48+
49+
/**
50+
* Append a single entry to the org_audit_log table.
51+
* Returns the inserted row, or null if the insert failed.
52+
*/
53+
async log(entry: OrgAuditEntry): Promise<OrgAuditRow | null> {
54+
const query = `
55+
INSERT INTO org_audit_log
56+
(organization_id, change_type, config_key, old_value, new_value,
57+
actor_id, actor_email, actor_ip)
58+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::inet)
59+
RETURNING *
60+
`;
61+
62+
try {
63+
const result = await this.db.query<OrgAuditRow>(query, [
64+
entry.organizationId,
65+
entry.changeType,
66+
entry.configKey ?? null,
67+
entry.oldValue !== undefined ? JSON.stringify(entry.oldValue) : null,
68+
entry.newValue !== undefined ? JSON.stringify(entry.newValue) : null,
69+
entry.actorId ?? null,
70+
entry.actorEmail ?? null,
71+
entry.actorIp ?? null,
72+
]);
73+
return result.rows[0] ?? null;
74+
} catch (err) {
75+
console.error('[OrgAuditService] Failed to write audit log entry:', err);
76+
return null;
77+
}
78+
}
79+
80+
/**
81+
* Retrieve paginated audit log entries for an organization.
82+
* Results are ordered newest-first.
83+
*/
84+
async list(
85+
organizationId: number,
86+
options: { limit?: number; offset?: number } = {}
87+
): Promise<{ rows: OrgAuditRow[]; total: number }> {
88+
const limit = Math.min(options.limit ?? 50, 200);
89+
const offset = options.offset ?? 0;
90+
91+
const [dataResult, countResult] = await Promise.all([
92+
this.db.query<OrgAuditRow>(
93+
`SELECT * FROM org_audit_log
94+
WHERE organization_id = $1
95+
ORDER BY created_at DESC
96+
LIMIT $2 OFFSET $3`,
97+
[organizationId, limit, offset]
98+
),
99+
this.db.query<{ count: string }>(
100+
`SELECT COUNT(*) AS count FROM org_audit_log WHERE organization_id = $1`,
101+
[organizationId]
102+
),
103+
]);
104+
105+
return {
106+
rows: dataResult.rows,
107+
total: parseInt(countResult.rows[0]?.count ?? '0', 10),
108+
};
109+
}
110+
}
111+
112+
export const orgAuditService = new OrgAuditService();

0 commit comments

Comments
 (0)