feat(api-keys): restrict keys to source IP addresses

A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:41:46 +02:00
1 parent 9ae1d96a32
commit f1dc0de3e2
16 files changed
+463 -10

No files matched your search

@@ -1,10 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomBytes, randomInt } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [
...new Set(
(entries ?? [])
.map(normalizeAllowlistEntry)
.filter((entry): entry is string => entry !== null),
),
];
}
// ack_<8 lowercase chars>_<32 random bytes, base64url>
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
@@ -48,9 +66,12 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
async findAll(): Promise<ApiKeySummary[]> {
@@ -64,6 +85,15 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
if (missing.length > 0) {
throw new ForbiddenException(
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
);
}
const prefix = randomPrefix();
// 32 random bytes: a plain hash of it is safe to store without a slow
// KDF - there is nothing guessable to brute-force.
@@ -79,6 +109,7 @@ export class ApiKeysService {
prefix,
keyHash: hashToken(token),
scopes: [...new Set(dto.scopes)],
allowedIps: normalizeAllowlist(dto.allowedIps),
expiresAt,
createdByUserId: actingUserId,
},
@@ -94,6 +125,31 @@ export class ApiKeysService {
return { apiKey: summary, token };
}
/** Changes where a key may be used from, without issuing a new one. */
async updateAllowedIps(
id: string,
dto: UpdateApiKeyDto,
actingUserId: string,
): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(updated),
});
return toSummary(updated);
}
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
@@ -121,7 +177,10 @@ export class ApiKeysService {
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired.
*/
async authenticate(token: string): Promise<ApiKeyPrincipal | null> {
async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
if (!TOKEN_PATTERN.test(token)) {
return null;
}
@@ -136,13 +195,24 @@ export class ApiKeysService {
) {
return null;
}
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
this.logger.warn(
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
);
throw new ForbiddenException(
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
);
}
if (
!apiKey.lastUsedAt ||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
apiKey.lastUsedIp !== (clientIp ?? null)
) {
await this.prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: now },
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
});
}
// Intersect with today's allowlist: narrowing it later also narrows