diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b4c501..763cba4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,6 +102,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. +14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. ## 7. Kubernetes diff --git a/ApiCore-Backend/prisma/migrations/20260927231113_add_api_keys/migration.sql b/ApiCore-Backend/prisma/migrations/20260927231113_add_api_keys/migration.sql new file mode 100644 index 0000000..b89e75c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260927231113_add_api_keys/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 7600c1d..04c2d28 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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 diff --git a/ApiCore-Backend/src/api-keys/api-key-scopes.ts b/ApiCore-Backend/src/api-keys/api-key-scopes.ts new file mode 100644 index 0000000..5744591 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-key-scopes.ts @@ -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); +} diff --git a/ApiCore-Backend/src/api-keys/api-keys.controller.ts b/ApiCore-Backend/src/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..aa544e5 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-keys.controller.ts @@ -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 { + return this.apiKeys.findAll(); + } + + @RequirePermission('api-keys:manage') + @Post() + create( + @Body() dto: CreateApiKeyDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.apiKeys.create(dto, actor.id); + } + + @RequirePermission('api-keys:manage') + @Post(':id/revoke') + revoke( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.apiKeys.revoke(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/api-keys/api-keys.module.ts b/ApiCore-Backend/src/api-keys/api-keys.module.ts new file mode 100644 index 0000000..22f967e --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-keys.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.ts b/ApiCore-Backend/src/api-keys/api-keys.service.ts new file mode 100644 index 0000000..bb978cb --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-keys.service.ts @@ -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; + +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 { + const keys = await this.prisma.apiKey.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return keys.map(toSummary); + } + + async create( + dto: CreateApiKeyDto, + actingUserId: string, + ): Promise { + 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 { + 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 { + 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(); +} diff --git a/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts b/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 0000000..86a9a7e --- /dev/null +++ b/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 5b34dfb..8ef6346 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts index ec7e307..a166a1a 100644 --- a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts @@ -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); + }); }); diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts index a1671c6..061a6d2 100644 --- a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts @@ -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(); + + // 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(); const user = request.user; if ( !user || diff --git a/ApiCore-Backend/src/auth/guards/permissions.guard.ts b/ApiCore-Backend/src/auth/guards/permissions.guard.ts index 94f9de1..ca53eb0 100644 --- a/ApiCore-Backend/src/auth/guards/permissions.guard.ts +++ b/ApiCore-Backend/src/auth/guards/permissions.guard.ts @@ -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(); 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; diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 09c302e..1f7a945 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -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']; diff --git a/ApiCore-Backend/test/api-keys.e2e-spec.ts b/ApiCore-Backend/test/api-keys.e2e-spec.ts new file mode 100644 index 0000000..cfacfd2 --- /dev/null +++ b/ApiCore-Backend/test/api-keys.e2e-spec.ts @@ -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; + 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) { + 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); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx new file mode 100644 index 0000000..fbe5a45 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api"; +import { API_KEY_SCOPE_LABELS, ApiKey, ApiKeyScope } from "@/lib/api-keys/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { ConfirmDialog } from "@/components/confirm-dialog"; + +const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"]; + +function formatDate(value: string | null): string { + return value + ? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" }) + : "—"; +} + +function keyState(apiKey: ApiKey): { label: string; active: boolean } { + if (apiKey.revokedAt) return { label: "Widerrufen", active: false }; + if (apiKey.expiresAt && new Date(apiKey.expiresAt) <= new Date()) { + return { label: "Abgelaufen", active: false }; + } + return { label: "Aktiv", active: true }; +} + +/** + * Keys for machine access (the Wabenhain shop sync) instead of a service + * account with a password. The key is shown once; ApiCore keeps only a hash. + */ +export function ApiKeysTab() { + const queryClient = useQueryClient(); + const [name, setName] = useState("Wabenhain-Shop"); + const [expiresInDays, setExpiresInDays] = useState(""); + const [created, setCreated] = useState<{ name: string; token: string } | null>(null); + const [copied, setCopied] = useState(false); + + const { data: keys, isLoading, isError } = useQuery({ + queryKey: ["api-keys"], + queryFn: fetchApiKeys, + }); + + const createMutation = useMutation({ + mutationFn: createApiKey, + onSuccess: (result) => { + setCreated({ name: result.apiKey.name, token: result.token }); + setCopied(false); + void queryClient.invalidateQueries({ queryKey: ["api-keys"] }); + }, + onError: () => toast.error("Schlüssel konnte nicht angelegt werden."), + }); + + const revokeMutation = useMutation({ + mutationFn: revokeApiKey, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["api-keys"] }); + toast.success("Schlüssel widerrufen – er funktioniert ab sofort nicht mehr."); + }, + onError: () => toast.error("Schlüssel konnte nicht widerrufen werden."), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const days = expiresInDays ? Number(expiresInDays) : undefined; + createMutation.mutate({ name: name.trim(), scopes: DEFAULT_SCOPES, expiresInDays: days }); + } + + async function copyToken() { + if (!created) return; + try { + await navigator.clipboard.writeText(created.token); + setCopied(true); + } catch { + toast.error("Kopieren nicht möglich – bitte den Schlüssel markieren und manuell kopieren."); + } + } + + return ( +
+

+ Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop, + die freigegebenen Shop-Daten zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und + lässt sich jederzeit widerrufen. +

+ + {created ? ( +
+
+ + Schlüssel „{created.name}“ angelegt +
+

+ Jetzt kopieren und in Wabenhain als APICORE_API_KEY{" "} + eintragen. Er wird nur dieses eine Mal angezeigt. +

+
+ + {created.token} + + +
+ +
+ ) : null} + +
+
+ + setName(event.target.value)} + /> +
+
+ + setExpiresInDays(event.target.value)} + /> +
+ +
+

+ Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]} +

+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ API-Schlüssel konnten nicht geladen werden. +

+ ) : null} + {keys && keys.length === 0 ? ( +

Noch keine API-Schlüssel angelegt.

+ ) : null} + + {keys && keys.length > 0 ? ( +
    + {keys.map((apiKey) => { + const state = keyState(apiKey); + return ( +
  • +
    + + {apiKey.name} + {state.label} + + {apiKey.prefix}_… + + Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "} + {formatDate(apiKey.lastUsedAt)} + {apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""} + +
    + {state.active ? ( + revokeMutation.mutate(apiKey.id)} + /> + ) : null} +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx index d3ebe2d..e0236ea 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx @@ -11,6 +11,7 @@ import { LabelTemplatesTab } from "./label-templates-tab"; import { SocialMediaTab } from "./social-media-tab"; import { AutomationRulesTab } from "./automation-rules-tab"; import { CalendarSyncTab } from "./calendar-sync-tab"; +import { ApiKeysTab } from "./api-keys-tab"; type Tab = | "webhooks" @@ -18,7 +19,8 @@ type Tab = | "label-templates" | "social-media" | "rules" - | "calendar-sync"; + | "calendar-sync" + | "api-keys"; const TABS: { value: Tab; label: string }[] = [ { value: "webhooks", label: "Webhooks" }, @@ -27,6 +29,7 @@ const TABS: { value: Tab; label: string }[] = [ { value: "social-media", label: "Social Media" }, { value: "rules", label: "Regeln" }, { value: "calendar-sync", label: "Kalender-Sync" }, + { value: "api-keys", label: "API-Schlüssel" }, ]; function isTab(value: string | null): value is Tab { @@ -47,7 +50,7 @@ function AutomationPageContent() { @@ -59,6 +62,7 @@ function AutomationPageContent() { {tab === "social-media" ? : null} {tab === "rules" ? : null} {tab === "calendar-sync" ? : null} + {tab === "api-keys" ? : null} ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/layout.tsx b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx index 3636ae5..3621666 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx @@ -18,6 +18,7 @@ const SETTINGS_AREA_PERMISSIONS = [ "backup:import", "backup:configure", "calendar:configure", + "api-keys:manage", ]; export default function SettingsLayout({ children }: { children: ReactNode }) { diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index 6af96ca..dbde445 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -105,6 +105,7 @@ const ADMIN_GROUP: NavGroup = { "label-templates:read", "site-settings:read", "calendar:configure", + "api-keys:manage", ], }, { diff --git a/ApiCore-Frontend/src/lib/api-keys/api.ts b/ApiCore-Frontend/src/lib/api-keys/api.ts new file mode 100644 index 0000000..7317c72 --- /dev/null +++ b/ApiCore-Frontend/src/lib/api-keys/api.ts @@ -0,0 +1,17 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { ApiKey, CreateApiKeyInput, CreatedApiKey } from "./types"; + +export function fetchApiKeys(): Promise { + return apiFetch("/api-keys"); +} + +export function createApiKey(input: CreateApiKeyInput): Promise { + return apiFetch("/api-keys", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function revokeApiKey(id: string): Promise { + return apiFetch(`/api-keys/${id}/revoke`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/api-keys/types.ts b/ApiCore-Frontend/src/lib/api-keys/types.ts new file mode 100644 index 0000000..96dbd5c --- /dev/null +++ b/ApiCore-Frontend/src/lib/api-keys/types.ts @@ -0,0 +1,29 @@ +export type ApiKeyScope = "shop-sync:read"; + +export const API_KEY_SCOPE_LABELS: Record = { + "shop-sync:read": "Shop-Daten lesen (Wabenhain)", +}; + +export interface ApiKey { + id: string; + name: string; + prefix: string; + scopes: ApiKeyScope[]; + createdByUserId: string | null; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; +} + +export interface CreateApiKeyInput { + name: string; + scopes: ApiKeyScope[]; + expiresInDays?: number; +} + +export interface CreatedApiKey { + apiKey: ApiKey; + /** Shown exactly once - the server keeps only a hash. */ + token: string; +} diff --git a/docs/pruefung-2026-09.md b/docs/pruefung-2026-09.md index b153952..4a1fa22 100644 --- a/docs/pruefung-2026-09.md +++ b/docs/pruefung-2026-09.md @@ -57,7 +57,7 @@ Umgesetzt in Wabenhain `d38f430` (AT: Entscheidungslogik; CR: DB-Ablauf der Serv 1. ~~Zuständigkeit pro Feld im Sync~~ – erledigt. 2. ~~Eigene, versionierte Sync-Schnittstelle~~ – erledigt: `GET /api/v1/shop-sync` (Vertragsversion 1, Feld-Allowlist, Berechtigung `shop-sync:read`). Bewusst ohne „geändert seit“: kleine Datenmenge, Voll-Abgleich heilt verpasste Läufe selbst. Wabenhain nutzt sie seit `5642f0c`, mit Rückfall auf die alten Endpunkte bei HTTP 404. -3. Nur-Lese-API-Schlüssel statt Passwort-Login des Service-Kontos. +3. ~~Nur-Lese-API-Schlüssel~~ – erledigt: Verwaltung → Automatisierung → API-Schlüssel. Nur Hash gespeichert, Klartext einmalig angezeigt, Scope nur `shop-sync:read`, widerrufbar, optional befristet, „zuletzt benutzt“ sichtbar. Wabenhain liest `APICORE_API_KEY` (Passwort-Login bleibt Rückfall). 4. Ende-zu-Ende-Test beider Systeme gegen Test-Instanzen. 5. Merge/Push.