feat: revocable read-only API keys for machine access

Integrations such as the Wabenhain shop sync no longer need a service
account with a password (and without MFA). An admin creates a key under
Verwaltung > Automatisierung > API-Schlüssel.

- Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is
  shown exactly once. Optional expiry, "last used" timestamp, revocation
  takes effect immediately; create/revoke are audit-logged without the key.
- A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard
  admits it only to routes whose permission is among its scopes and never
  to user-only routes. Scopes come from a short allowlist (shop-sync:read).
- Managing keys requires the new permission api-keys:manage.
- Keys are excluded from backups on purpose; a restore means issuing a new
  key.

Additive migration (new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 01:24:17 +02:00
1 parent ede86c062a
commit 445fb3b94b
21 files changed
+848 -5

No files matched your search

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[],
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
-- CreateIndex
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+24
View File
@@ -111,10 +111,34 @@ model User {
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@index([roleId])
}
// Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines
// Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des
// Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt.
// Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren
// Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts).
model ApiKey {
id String @id @default(cuid())
name String
// Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der
// Liste, ohne den Schlüssel selbst zu speichern.
prefix String
keyHash String @unique
scopes String[]
createdByUserId String?
createdByUser User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
@@index([revokedAt])
}
model RefreshToken {
id String @id @default(cuid())
userId String
@@ -0,0 +1,18 @@
import { PermissionKey } from '../auth/permissions/permission-catalog';
/**
* Permissions an API key may carry. Deliberately a short allowlist of
* read-only permissions, not the whole catalog: a key is a long-lived secret
* stored in another system, so it only ever gets what a machine integration
* needs. Removing an entry here also takes it away from existing keys (see
* ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
@@ -0,0 +1,42 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import {
ApiKeySummary,
ApiKeysService,
CreatedApiKey,
} from './api-keys.service';
@ApiTags('api-keys')
@ApiBearerAuth()
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeys: ApiKeysService) {}
@RequirePermission('api-keys:manage')
@Get()
findAll(): Promise<ApiKeySummary[]> {
return this.apiKeys.findAll();
}
@RequirePermission('api-keys:manage')
@Post()
create(
@Body() dto: CreateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<CreatedApiKey> {
return this.apiKeys.create(dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Post(':id/revoke')
revoke(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.revoke(id, actor.id);
}
}
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
@Global()
@Module({
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -0,0 +1,162 @@
import { Injectable, 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';
// ack_<8 lowercase chars>_<32 random bytes, base64url>
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
id: string;
scopes: ApiKeyScope[];
}
/** An API key as shown in the admin UI - never the hash or the key itself. */
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
export interface CreatedApiKey {
apiKey: ApiKeySummary;
/** The only time the plain key exists outside the caller's system. */
token: string;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function randomPrefix(): string {
let prefix = '';
for (let i = 0; i < 8; i++) {
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
}
return `ack_${prefix}`;
}
function toSummary(apiKey: ApiKey): ApiKeySummary {
const { keyHash, ...summary } = apiKey;
void keyHash;
return summary;
}
@Injectable()
export class ApiKeysService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(): Promise<ApiKeySummary[]> {
const keys = await this.prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
});
return keys.map(toSummary);
}
async create(
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
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.
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
const expiresAt =
dto.expiresInDays !== undefined
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await this.prisma.apiKey.create({
data: {
name: dto.name.trim(),
prefix,
keyHash: hashToken(token),
scopes: [...new Set(dto.scopes)],
expiresAt,
createdByUserId: actingUserId,
},
});
const summary = toSummary(apiKey);
await this.auditLog.record({
entityType: 'ApiKey',
entityId: apiKey.id,
action: 'CREATE',
userId: actingUserId,
afterData: summary,
});
return { apiKey: summary, token };
}
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
if (before.revokedAt) {
return toSummary(before);
}
const revoked = await this.prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(revoked),
});
return toSummary(revoked);
}
/**
* 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> {
if (!TOKEN_PATTERN.test(token)) {
return null;
}
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) },
});
const now = new Date();
if (
!apiKey ||
apiKey.revokedAt ||
(apiKey.expiresAt && apiKey.expiresAt <= now)
) {
return null;
}
if (
!apiKey.lastUsedAt ||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS
) {
await this.prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: now },
});
}
// Intersect with today's allowlist: narrowing it later also narrows
// keys issued before.
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
}
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
): string | null {
if (!authorization?.startsWith('Bearer ack_')) {
return null;
}
return authorization.slice('Bearer '.length).trim();
}
@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { API_KEY_SCOPES } from '../api-key-scopes';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
})
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@IsIn(API_KEY_SCOPES, { each: true })
scopes!: string[];
@ApiPropertyOptional({
description:
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(730)
expiresInDays?: number;
}
+2
View File
@@ -45,6 +45,7 @@ import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -99,6 +100,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
SiteSettingsModule,
BackupModule,
ShopSyncModule,
ApiKeysModule,
],
controllers: [],
providers: [
@@ -2,16 +2,19 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { ApiKeysService } from '../../api-keys/api-keys.service';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { isTokenValid: jest.Mock };
let apiKeys: { authenticate: jest.Mock };
function contextWithUser(
user: { id: string; tokenIssuedAt?: number } | undefined,
authorization?: string,
): ExecutionContext {
const request = { user };
const request = { user, headers: { authorization } };
return {
getHandler: () => ({}),
getClass: () => ({}),
@@ -22,9 +25,11 @@ describe('JwtAuthGuard', () => {
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
permissionResolver = { isTokenValid: jest.fn() };
apiKeys = { authenticate: jest.fn() };
guard = new JwtAuthGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
apiKeys as unknown as ApiKeysService,
);
});
@@ -72,4 +77,27 @@ describe('JwtAuthGuard', () => {
UnauthorizedException,
);
});
it('authenticates an API key as a machine principal, not as a user', async () => {
const principal = { id: 'key-1', scopes: ['shop-sync:read'] };
apiKeys.authenticate.mockResolvedValue(principal);
const context = contextWithUser(undefined, 'Bearer ack_abcdefgh_secret');
await expect(guard.canActivate(context)).resolves.toBe(true);
const request = context.switchToHttp().getRequest<{
apiKey?: unknown;
user?: unknown;
}>();
expect(request.apiKey).toBe(principal);
expect(request.user).toBeUndefined();
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
});
it('rejects an unknown, revoked or expired API key', async () => {
apiKeys.authenticate.mockResolvedValue(null);
await expect(
guard.canActivate(contextWithUser(undefined, 'Bearer ack_abcdefgh_x')),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -8,9 +8,16 @@ import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
import {
ApiKeyPrincipal,
ApiKeysService,
extractApiKeyToken,
} from '../../api-keys/api-keys.service';
interface RequestWithUser {
headers: { authorization?: string };
user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
}
/**
@@ -28,6 +35,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService,
private readonly apiKeys: ApiKeysService,
) {
super();
}
@@ -40,11 +48,25 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
// request.user is set, so it can never pass a user-only route, and
// PermissionsGuard checks its scopes instead of a role.
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
if (apiKeyToken !== null) {
const principal = await this.apiKeys.authenticate(apiKeyToken);
if (!principal) {
throw new UnauthorizedException();
}
request.apiKey = principal;
return true;
}
const authenticated = (await super.canActivate(context)) as boolean;
if (!authenticated) {
return false;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (
!user ||
@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
interface RequestWithUser {
user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
}
/**
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
// API keys pass only routes that name a permission the key carries -
// never @AnyAuthenticatedUser routes, which are meant for people.
if (request.apiKey) {
const scopes: readonly string[] = request.apiKey.scopes;
return Boolean(
requiredPermissions?.some((permission) => scopes.includes(permission)),
);
}
if (requiredPermissions && requiredPermissions.length > 0) {
if (!user) {
return false;
@@ -315,6 +315,12 @@ export const PERMISSION_CATALOG = [
[['read', 'Freigegebene Daten für den Shop lesen']],
3300,
),
...group(
'API-Schlüssel',
'api-keys',
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
+193
View File
@@ -0,0 +1,193 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface CreatedKeyBody {
apiKey: {
id: string;
name: string;
prefix: string;
scopes: string[];
revokedAt: string | null;
};
token: string;
}
/**
* Read-only API keys for machine access (the shop sync): shown once, stored
* only as a hash, limited to their scopes, revocable.
*/
describe('API keys (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let staffToken: string;
const server = () => app.getHttpServer();
function createKey(body: Record<string, unknown>) {
return request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send(body);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email: `keys-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
await prisma.user.create({
data: {
email: `keys-staff-${suffix}@example.com`,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
adminToken = (
await login(app, `keys-admin-${suffix}@example.com`, password)
).accessToken;
staffToken = (
await login(app, `keys-staff-${suffix}@example.com`, password)
).accessToken;
});
afterAll(async () => {
await app.close();
});
it('shows the key once and stores only a hash', async () => {
const response = await createKey({
name: `Wabenhain ${suffix}`,
scopes: ['shop-sync:read'],
}).expect(201);
const body = response.body as CreatedKeyBody;
expect(body.token).toMatch(/^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/);
expect(body.token.startsWith(body.apiKey.prefix)).toBe(true);
expect(JSON.stringify(body.apiKey)).not.toContain(body.token);
const stored = await prisma.apiKey.findUniqueOrThrow({
where: { id: body.apiKey.id },
});
expect(stored.keyHash).not.toContain(body.token);
const list = await request(server())
.get('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const raw = JSON.stringify(list.body);
expect(raw).not.toContain(body.token);
expect(raw).not.toContain('keyHash');
});
it('lets a key read exactly what its scopes allow', async () => {
const { token } = (
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
).body as CreatedKeyBody;
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${token}`)
.expect(200);
// Not a user: no other data, no user-only routes.
await request(server())
.get('/api/v1/harvests')
.set('Authorization', `Bearer ${token}`)
.expect(403);
await request(server())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
it('records when a key was last used', async () => {
const created = (
await createKey({ name: `Used ${suffix}`, scopes: ['shop-sync:read'] })
).body as CreatedKeyBody;
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${created.token}`)
.expect(200);
const stored = await prisma.apiKey.findUniqueOrThrow({
where: { id: created.apiKey.id },
});
expect(stored.lastUsedAt).not.toBeNull();
});
it('stops working immediately once revoked', async () => {
const created = (
await createKey({ name: `Revoke ${suffix}`, scopes: ['shop-sync:read'] })
).body as CreatedKeyBody;
await request(server())
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${created.token}`)
.expect(401);
});
it('rejects an expired key', async () => {
const created = (
await createKey({
name: `Expired ${suffix}`,
scopes: ['shop-sync:read'],
expiresInDays: 1,
})
).body as CreatedKeyBody;
await prisma.apiKey.update({
where: { id: created.apiKey.id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${created.token}`)
.expect(401);
});
it('rejects unknown and tampered keys', async () => {
const created = (
await createKey({ name: `Tamper ${suffix}`, scopes: ['shop-sync:read'] })
).body as CreatedKeyBody;
const tampered = `${created.token.slice(0, 20)}${created.token[20] === 'A' ? 'B' : 'A'}${created.token.slice(21)}`;
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${tampered}`)
.expect(401);
await request(server())
.get('/api/v1/shop-sync')
.set('Authorization', 'Bearer ack_nope')
.expect(401);
});
it('only allows scopes released for keys', async () => {
await createKey({
name: `Too much ${suffix}`,
scopes: ['harvests:read'],
}).expect(400);
});
it('can only be managed with api-keys:manage', async () => {
await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${staffToken}`)
.send({ name: 'x', scopes: ['shop-sync:read'] })
.expect(403);
});
});