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]>
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
|
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
|
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Fails closed by decorator absence: a route reaches this guard whether or
|
|
* not it's @Public (JwtAuthGuard only skips authentication for those, it
|
|
* doesn't remove this guard from the chain - NestJS APP_GUARD entries all
|
|
* run, there's no early-exit between them), so @Public is checked here too,
|
|
* the same way JwtAuthGuard checks it. A non-public route is denied unless
|
|
* it carries either @RequirePermission (checked against the caller's live
|
|
* permission set) or the explicit @AnyAuthenticatedUser opt-out for routes
|
|
* meant to be reachable by any logged-in user regardless of role. A route
|
|
* with none of the three is a bug - forgetting to decorate a new route now
|
|
* fails shut at runtime instead of silently granting access to every
|
|
* authenticated user and relying solely on
|
|
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
|
|
*/
|
|
@Injectable()
|
|
export class PermissionsGuard implements CanActivate {
|
|
constructor(
|
|
private readonly reflector: Reflector,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
if (isPublic) {
|
|
return true;
|
|
}
|
|
|
|
const requiredPermissions = this.reflector.getAllAndOverride<
|
|
PermissionKey[]
|
|
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
|
|
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;
|
|
}
|
|
return this.permissionResolver.hasAny(user.id, requiredPermissions);
|
|
}
|
|
|
|
const isAnyAuthenticatedUser = this.reflector.getAllAndOverride<boolean>(
|
|
ANY_AUTHENTICATED_USER_KEY,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
return Boolean(isAnyAuthenticatedUser && user);
|
|
}
|
|
}
|