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:
1 parent
ede86c062a
commit
445fb3b94b
21 files changed
+848
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{created ? (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className="flex flex-col gap-3 rounded-2xl border border-primary/40 bg-primary/5 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-5 text-primary" aria-hidden />
|
||||
Schlüssel „{created.name}“ angelegt
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "}
|
||||
eintragen. Er wird <strong>nur dieses eine Mal</strong> angezeigt.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg bg-card px-3 py-2 font-mono text-xs select-all">
|
||||
{created.token}
|
||||
</code>
|
||||
<Button size="touch" variant="outline" onClick={() => void copyToken()}>
|
||||
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
||||
{copied ? "Kopiert" : "Kopieren"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="self-start" onClick={() => setCreated(null)}>
|
||||
Erledigt, Hinweis schließen
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-56 flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="api-key-name">Wofür ist der Schlüssel?</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
className="h-11"
|
||||
value={name}
|
||||
maxLength={80}
|
||||
required
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-44 flex-col gap-1.5">
|
||||
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
|
||||
<Input
|
||||
id="api-key-expiry"
|
||||
className="h-11"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={730}
|
||||
placeholder="bis Widerruf"
|
||||
value={expiresInDays}
|
||||
onChange={(event) => setExpiresInDays(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
|
||||
<KeyRound aria-hidden />
|
||||
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="-mt-3 text-xs text-muted-foreground">
|
||||
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}
|
||||
</p>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
API-Schlüssel konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
{keys && keys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine API-Schlüssel angelegt.</p>
|
||||
) : null}
|
||||
|
||||
{keys && keys.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{keys.map((apiKey) => {
|
||||
const state = keyState(apiKey);
|
||||
return (
|
||||
<li
|
||||
key={apiKey.id}
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl border border-border/60 bg-card p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2 font-medium">
|
||||
{apiKey.name}
|
||||
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
||||
{formatDate(apiKey.lastUsedAt)}
|
||||
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{state.active ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Widerrufen"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={revokeMutation.isPending}
|
||||
title="Schlüssel widerrufen?"
|
||||
description={`„${apiKey.name}“ funktioniert danach sofort nicht mehr. Das angebundene System braucht dann einen neuen Schlüssel.`}
|
||||
confirmLabel="Widerrufen"
|
||||
destructive
|
||||
isPending={revokeMutation.isPending}
|
||||
onConfirm={() => revokeMutation.mutate(apiKey.id)}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<PageHeader
|
||||
icon={Zap}
|
||||
title="Automatisierung"
|
||||
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln und Kalender-Sync"
|
||||
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln, Kalender-Sync und API-Schlüssel"
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
|
||||
@@ -59,6 +62,7 @@ function AutomationPageContent() {
|
||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||
{tab === "api-keys" ? <ApiKeysTab /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const SETTINGS_AREA_PERMISSIONS = [
|
||||
"backup:import",
|
||||
"backup:configure",
|
||||
"calendar:configure",
|
||||
"api-keys:manage",
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -105,6 +105,7 @@ const ADMIN_GROUP: NavGroup = {
|
||||
"label-templates:read",
|
||||
"site-settings:read",
|
||||
"calendar:configure",
|
||||
"api-keys:manage",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { ApiKey, CreateApiKeyInput, CreatedApiKey } from "./types";
|
||||
|
||||
export function fetchApiKeys(): Promise<ApiKey[]> {
|
||||
return apiFetch<ApiKey[]>("/api-keys");
|
||||
}
|
||||
|
||||
export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
|
||||
return apiFetch<CreatedApiKey>("/api-keys", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeApiKey(id: string): Promise<ApiKey> {
|
||||
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ApiKeyScope = "shop-sync:read";
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user