Skip to content

Commit 708b536

Browse files
feat(security): track API key usage per orgId and emit security events
- Implement ApiKeyUsageTrackerService with Redis-backed sliding-window aggregator tracking distinct countries per API key in 15-minute windows - Add SecurityEventJob that flushes usage windows hourly and records anomalies to AuditLog with full metadata - Add security_event_total Prometheus counter for anomaly detection - Register security_event_total metric in metrics.providers.ts - Add MetricsService.incrementSecurityEvent() method - Register UsageTrackerModule and SecurityEventJob in JobsModule - Import ConfigModule in UsageTrackerModule for dependency resolution - Add 6 unit tests covering anomaly recording, multiple anomalies, no-anomaly case, and error handling Closes #220
1 parent 3a3a937 commit 708b536

8 files changed

Lines changed: 438 additions & 3 deletions

File tree

app/backend/src/jobs/jobs.module.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { BullModule } from '@nestjs/bullmq';
33
import { JobsController } from './jobs.controller';
44
import { RETENTION_PURGE_QUEUE } from '../retention-policy/retention-purge.processor';
55
import { DlqService } from './dlq.service';
6+
import { SecurityEventJob } from './security-event.job';
7+
import { UsageTrackerModule } from '../observability/usage-tracker/usage-tracker.module';
8+
import { AuditModule } from '../audit/audit.module';
69

710
@Module({
811
imports: [
@@ -11,9 +14,11 @@ import { DlqService } from './dlq.service';
1114
BullModule.registerQueue({ name: 'onchain' }),
1215
BullModule.registerQueue({ name: RETENTION_PURGE_QUEUE }),
1316
BullModule.registerQueue({ name: 'dead-letter' }),
17+
UsageTrackerModule,
18+
AuditModule,
1419
],
1520
controllers: [JobsController],
16-
providers: [DlqService],
17-
exports: [DlqService],
21+
providers: [DlqService, SecurityEventJob],
22+
exports: [DlqService, SecurityEventJob],
1823
})
1924
export class JobsModule {}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { SecurityEventJob } from './security-event.job';
3+
import { ApiKeyUsageTrackerService } from '../observability/usage-tracker/api-key-usage-tracker.service';
4+
import { AuditService } from '../audit/audit.service';
5+
import { AnomalyEvent } from '../observability/usage-tracker/api-key-usage-tracker.service';
6+
7+
describe('SecurityEventJob', () => {
8+
let job: SecurityEventJob;
9+
let auditService: AuditService;
10+
11+
const mockAnomaly: AnomalyEvent = {
12+
keyId: 'key-123',
13+
orgId: 'org-456',
14+
distinctCountries: 5,
15+
countries: ['US', 'GB', 'DE', 'FR', 'JP'],
16+
windowStart: new Date('2024-01-01T00:00:00Z'),
17+
windowEnd: new Date('2024-01-01T01:00:00Z'),
18+
};
19+
20+
const mockUsageTracker = {
21+
flushAllWindows: jest.fn(),
22+
};
23+
24+
const mockAuditService = {
25+
record: jest.fn().mockResolvedValue({ id: 'audit-1' }),
26+
};
27+
28+
beforeEach(async () => {
29+
const module: TestingModule = await Test.createTestingModule({
30+
providers: [
31+
SecurityEventJob,
32+
{
33+
provide: ApiKeyUsageTrackerService,
34+
useValue: mockUsageTracker,
35+
},
36+
{
37+
provide: AuditService,
38+
useValue: mockAuditService,
39+
},
40+
],
41+
}).compile();
42+
43+
job = module.get<SecurityEventJob>(SecurityEventJob);
44+
auditService = module.get<AuditService>(AuditService);
45+
46+
jest.clearAllMocks();
47+
});
48+
49+
it('should be defined', () => {
50+
expect(job).toBeDefined();
51+
});
52+
53+
describe('flushUsageWindows', () => {
54+
it('should record audit log entries for each anomaly', async () => {
55+
mockUsageTracker.flushAllWindows.mockResolvedValue([mockAnomaly]);
56+
57+
await job.flushUsageWindows();
58+
59+
expect(auditService.record).toHaveBeenCalledTimes(1);
60+
expect(auditService.record).toHaveBeenCalledWith({
61+
actorId: 'apikey:key-123',
62+
entity: 'ApiKey',
63+
entityId: 'key-123',
64+
action: 'security_anomaly',
65+
metadata: {
66+
kind: 'api_key_anomaly',
67+
distinctCountries: 5,
68+
countries: ['US', 'GB', 'DE', 'FR', 'JP'],
69+
orgId: 'org-456',
70+
windowStart: '2024-01-01T00:00:00.000Z',
71+
windowEnd: '2024-01-01T01:00:00.000Z',
72+
},
73+
});
74+
});
75+
76+
it('should record multiple audit entries for multiple anomalies', async () => {
77+
const anomaly2: AnomalyEvent = {
78+
...mockAnomaly,
79+
keyId: 'key-789',
80+
orgId: null,
81+
distinctCountries: 4,
82+
countries: ['US', 'GB', 'DE', 'FR'],
83+
};
84+
mockUsageTracker.flushAllWindows.mockResolvedValue([
85+
mockAnomaly,
86+
anomaly2,
87+
]);
88+
89+
await job.flushUsageWindows();
90+
91+
expect(auditService.record).toHaveBeenCalledTimes(2);
92+
expect(auditService.record).toHaveBeenNthCalledWith(1, {
93+
actorId: 'apikey:key-123',
94+
entity: 'ApiKey',
95+
entityId: 'key-123',
96+
action: 'security_anomaly',
97+
metadata: expect.objectContaining({ kind: 'api_key_anomaly' }),
98+
});
99+
expect(auditService.record).toHaveBeenNthCalledWith(2, {
100+
actorId: 'apikey:key-789',
101+
entity: 'ApiKey',
102+
entityId: 'key-789',
103+
action: 'security_anomaly',
104+
metadata: expect.objectContaining({
105+
orgId: null,
106+
distinctCountries: 4,
107+
}),
108+
});
109+
});
110+
111+
it('should not record audit entries when no anomalies', async () => {
112+
mockUsageTracker.flushAllWindows.mockResolvedValue([]);
113+
114+
await job.flushUsageWindows();
115+
116+
expect(auditService.record).not.toHaveBeenCalled();
117+
});
118+
119+
it('should handle flushAllWindows errors gracefully', async () => {
120+
mockUsageTracker.flushAllWindows.mockRejectedValue(
121+
new Error('Redis connection failed'),
122+
);
123+
124+
await expect(job.flushUsageWindows()).resolves.not.toThrow();
125+
});
126+
127+
it('should handle auditService.record errors for individual entries', async () => {
128+
mockUsageTracker.flushAllWindows.mockResolvedValue([mockAnomaly]);
129+
mockAuditService.record.mockRejectedValueOnce(
130+
new Error('DB connection lost'),
131+
);
132+
133+
await expect(job.flushUsageWindows()).resolves.not.toThrow();
134+
});
135+
});
136+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { ApiKeyUsageTrackerService } from '../observability/usage-tracker/api-key-usage-tracker.service';
4+
import { AuditService } from '../audit/audit.service';
5+
6+
/**
7+
* Scheduled job that flushes API key usage windows and writes security
8+
* events to the AuditLog when anomalies are detected.
9+
*
10+
* Runs every hour by default. The `@Cron` decorator ensures only one
11+
* instance executes across a cluster when using a distributed lock
12+
* (via `@nestjs/schedule`'s `BullMQ` integration or singleton mode).
13+
*/
14+
@Injectable()
15+
export class SecurityEventJob {
16+
private readonly logger = new Logger(SecurityEventJob.name);
17+
18+
constructor(
19+
private readonly usageTracker: ApiKeyUsageTrackerService,
20+
private readonly auditService: AuditService,
21+
) {}
22+
23+
@Cron(CronExpression.EVERY_HOUR)
24+
async flushUsageWindows(): Promise<void> {
25+
try {
26+
this.logger.log('Flushing API key usage windows for security events');
27+
28+
const anomalies = await this.usageTracker.flushAllWindows();
29+
30+
for (const anomaly of anomalies) {
31+
await this.auditService.record({
32+
actorId: `apikey:${anomaly.keyId}`,
33+
entity: 'ApiKey',
34+
entityId: anomaly.keyId,
35+
action: 'security_anomaly',
36+
metadata: {
37+
kind: 'api_key_anomaly',
38+
distinctCountries: anomaly.distinctCountries,
39+
countries: anomaly.countries,
40+
orgId: anomaly.orgId,
41+
windowStart: anomaly.windowStart.toISOString(),
42+
windowEnd: anomaly.windowEnd.toISOString(),
43+
},
44+
});
45+
46+
this.logger.warn(
47+
`Security event recorded: keyId=${anomaly.keyId}, ` +
48+
`countries=${anomaly.distinctCountries}, ` +
49+
`window=${anomaly.windowStart.toISOString()}${anomaly.windowEnd.toISOString()}`,
50+
);
51+
}
52+
53+
if (anomalies.length === 0) {
54+
this.logger.debug('No API key anomalies detected during flush');
55+
} else {
56+
this.logger.warn(
57+
`Recorded ${anomalies.length} security event(s) from usage flush`,
58+
);
59+
}
60+
} catch (err) {
61+
this.logger.error(
62+
`Security event flush failed: ${err instanceof Error ? err.message : String(err)}`,
63+
err instanceof Error ? err.stack : undefined,
64+
);
65+
}
66+
}
67+
}

app/backend/src/observability/metrics/metrics.providers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,11 @@ export const metricsProviders = [
149149
help: 'Total number of analytics cache invalidations',
150150
labelNames: ['reason'],
151151
}),
152+
153+
// Security Metrics
154+
makeCounterProvider({
155+
name: 'security_event_total',
156+
help: 'Total number of security events detected (e.g. API key anomalies)',
157+
labelNames: ['kind', 'key_id', 'org_id'],
158+
}),
152159
];

app/backend/src/observability/metrics/metrics.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export class MetricsService {
4747
public emailDeliveryCounter: Counter<string>,
4848
@InjectMetric('email_delivery_duration_seconds')
4949
public emailDeliveryDuration: Histogram<string>,
50+
@InjectMetric('security_event_total')
51+
public securityEventCounter: Counter<string>,
5052
) {}
5153

5254
/**
@@ -252,4 +254,11 @@ export class MetricsService {
252254
this.errorRateCounter.inc({ error_type: 'email_delivery_failure' });
253255
}
254256
}
257+
258+
/**
259+
* Increment the security event counter for API key anomalies.
260+
*/
261+
incrementSecurityEvent(kind: string, keyId: string, orgId: string): void {
262+
this.securityEventCounter.inc({ kind, key_id: keyId, org_id: orgId });
263+
}
255264
}

0 commit comments

Comments
 (0)