Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
53fab102f2
347 files changed
+26087
-2141
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
@@ -11,11 +12,18 @@ 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 {
|
||||
ApiKeyScope,
|
||||
combinesReadAndWrite,
|
||||
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';
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||
|
||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||
// revealing its secret part.
|
||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Unknown, revoked or expired keys per client network before that network
|
||||
* gets 429 for further invalid keys for the rest of the window - the same
|
||||
* budget as the shop's ERP export. It slows down guessing and a
|
||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||
* anyone able to send requests from (or forge) a shared address - the
|
||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||
* holds 256 random bits, so letting it through costs nothing.
|
||||
*/
|
||||
export const API_KEY_FAILURE_LIMIT = {
|
||||
maxFailures: 20,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
maxTrackedSources: 10_000,
|
||||
} as const;
|
||||
|
||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||
|
||||
/** What a request authenticated by an API key may do - it is not a user. */
|
||||
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||
private readonly failures: FailedAttemptLimiter,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ApiKeySummary[]> {
|
||||
@@ -110,6 +104,14 @@ export class ApiKeysService {
|
||||
dto: CreateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
// Reading and booking are separate keys: a leaked read key cannot book,
|
||||
// a leaked booking key cannot read. Existing combined keys stay valid.
|
||||
// erp-inventory:read is a read scope too.
|
||||
if (combinesReadAndWrite(dto.scopes)) {
|
||||
throw new BadRequestException(
|
||||
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||
);
|
||||
}
|
||||
// Nobody hands a machine more than they may do themselves.
|
||||
const callerPermissions =
|
||||
await this.permissionResolver.getPermissions(actingUserId);
|
||||
@@ -224,20 +226,21 @@ export class ApiKeysService {
|
||||
if (reason === null && apiKey) {
|
||||
return this.accept(apiKey, clientIp, now);
|
||||
}
|
||||
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||
private recordRejection(
|
||||
private async recordRejection(
|
||||
token: string,
|
||||
reason: RejectReason,
|
||||
clientIp: string | undefined,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||
// addresses inside one's own prefix does not reset the budget.
|
||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||
if (this.failures.isBlocked(subject)) {
|
||||
const verdict = await this.failures.recordFailure(subject);
|
||||
if (verdict.blocked) {
|
||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||
// bare string), so clients can read `message` uniformly.
|
||||
throw new HttpException(
|
||||
@@ -254,9 +257,9 @@ export class ApiKeysService {
|
||||
this.logger.warn(
|
||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||
);
|
||||
if (this.failures.recordFailure(subject)) {
|
||||
if (verdict.limitReached) {
|
||||
this.logger.warn(
|
||||
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
||||
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user