Das nimmst du mit
- AuthN und AuthZ trennen
- Policies explizit machen
- Mandanten isolieren
- Audit beweiskräftig halten
Kapitelkompass
Authentifizierung liefert Identität; Autorisierung bewertet Kontext, Ressource, Mandant und konkrete Berechtigung.
Ein Support-Mitarbeiter darf Metadaten sehen, aber keine Zahlungsdetails eines fremden Mandanten.
Berechtigungen an fachlichen Aktionen ausrichten, nicht an zufälligen Controller-Methoden.
Breite ADMIN-Rollen umgehen Ownership und erschweren spätere Least-Privilege-Regeln.
Security Deep Dive: Authentifizierung, Autorisierung, Rollen, Permissions, OAuth2/OIDC-Mentalmodell, JWT, Service-to-Service, Audit und sichere Fehlerantworten.
In einfachen Tutorials bedeutet Security oft: Benutzer meldet sich
an, danach wird ein Controller mit @RolesAllowed("ADMIN")
geschuetzt. In echten Enterprise-Systemen reicht das fast nie. Eine
Order-Plattform muss unterscheiden, ob ein Kunde seine eigene Bestellung
liest, ob Support fremde Bestellungen lesen darf, ob Billing Rechnungen
stornieren darf, ob ein Payment-Service Zahlungen autorisieren darf und
ob Audit-Logs nur fuer Revisionsrollen sichtbar sind.
Das zentrale Missverstaendnis lautet: Authentifizierung ist nicht Autorisierung. Authentifizierung sagt, wer der Akteur ist. Autorisierung sagt, ob dieser Akteur diese konkrete Aktion auf dieser konkreten Ressource ausfuehren darf. Eine Rolle ist dabei nur ein grober Baustein. Die eigentliche Entscheidung braucht meistens Ressource, Mandant, Besitzer, Status, Scope, Zeit, Risiko und Aktion.
if (user.role().equals("ADMIN")) {
return orderRepository.findById(orderId);
}
throw new ForbiddenException();
Diese Loesung wirkt einfach, aber sie ist fachlich zu grob. Sie beantwortet nicht, ob der Kunde seine eigene Bestellung sehen darf, ob Support nur lesen, aber nicht stornieren darf, ob Billing andere Rechte braucht und ob ein fremder Mandant versteckt werden muss.
AccessDecision decision = orderReadPolicy.evaluate(securityContext, order);
Die Policy sieht den Akteur, die Ressource und die Aktion. Dadurch
kann sie entscheiden: Kunde liest eigene Bestellung, Support liest
beliebige Bestellung, fremder Mandant wird als
404 Not Found verborgen, Admin bekommt Sonderrechte.
Authentifizierung besteht nicht nur aus dem Pruefen eines Tokens. Ein Enterprise-System muss einen technischen Nachweis in einen fachlichen Kontext umwandeln. Dieser Kontext enthaelt Subjekt, Tenant, Rollen, Scopes, Authentifizierungszeitpunkt, Remote-Adresse und Correlation ID.
public record SecurityPrincipal(
String subject,
String tenantId,
Set<Role> roles,
Set<String> scopes,
Instant authenticatedAt
) {
}
Der SecurityContext ist bewusst fachlich. Controller,
Application Services und Policies sollen nicht direkt mit rohen
JWT-Strings arbeiten. Token sind Transportartefakte. Fachliche
Sicherheitsentscheidungen brauchen normalisierte Akteure.
Eine robuste Validierung prueft mindestens:
Das Lab verwendet bewusst einen kleinen HMAC-basierten Token-Service. Das ist kein Produktions-JWT-Framework, sondern ein Lernmodell. Es macht sichtbar, welche Schritte ein Framework spaeter fuer dich uebernimmt.
TokenClaims claims = tokenService.validate(token, clock.instant());
SecurityPrincipal principal = new SecurityPrincipal(
claims.subject(), claims.tenantId(), claims.roles(), claims.scopes(), clock.instant());
Wichtig: In Produktion sollte man etablierte JOSE/OIDC-Bibliotheken und die Security-Funktion des Frameworks verwenden. Die fachliche Entscheidung bleibt trotzdem deine Aufgabe.
RBAC ist gut fuer grobe Verantwortungen. Permissions sind besser fuer konkrete Aktionen. Ownership ist notwendig, wenn Benutzer eigene Ressourcen sehen oder aendern duerfen. Tenant-Grenzen verhindern, dass Daten zwischen Mandanten sichtbar werden.
public enum Role {
CUSTOMER(EnumSet.of(Permission.ORDER_READ_OWN, Permission.ORDER_CANCEL_OWN)),
SUPPORT(EnumSet.of(Permission.ORDER_READ_ANY)),
BILLING(EnumSet.of(Permission.INVOICE_READ_ANY, Permission.INVOICE_CANCEL)),
PAYMENT_SERVICE(EnumSet.of(Permission.PAYMENT_AUTHORIZE)),
AUDITOR(EnumSet.of(Permission.AUDIT_READ)),
ADMIN(EnumSet.of(Permission.ADMIN_ALL));
}
Eine Rolle wie SUPPORT darf in diesem Modell
Bestellungen lesen, aber nicht automatisch Zahlungen ausloesen oder
Rechnungen stornieren. Das verhindert Rechteausweitung durch zu breite
Rollen.
boolean isOwner = principal.subject().equals(order.owner().value());
if (isOwner && principal.hasPermission(Permission.ORDER_READ_OWN)) {
return AccessDecision.allowed("owner grants ORDER_READ_OWN");
}
Diese Regel ist fachlich: Der Kunde darf seine eigene Bestellung
lesen. Das ist nicht dasselbe wie eine globale
ORDER_READ_ANY-Berechtigung.
if (!principal.tenantId().equals("tenant-main")) {
return AccessDecision.denied("wrong tenant", DenialVisibility.HIDE_AS_NOT_FOUND);
}
Ein fremder Tenant bekommt bewusst nicht 403, sondern
kann als 404 verborgen werden. Das reduziert Information
Disclosure: Der Client lernt nicht, ob die Ressource existiert.
Autorisierung in Controllern wird schnell unwartbar. Heute steht dort
if support, morgen kommt Tenant-Pruefung, danach
Status-Regel, danach Audit und Sonderfall fuer Compliance. Besser ist
eine ausdrueckliche Policy.
public interface AccessPolicy<T> {
AccessDecision evaluate(SecurityContext context, T resource);
}
Die Policy ist testbar. Sie kann in REST, Messaging, Batchjobs und internen Services wiederverwendet werden. Sie entkoppelt Security-Regeln von Transporttechnik.
public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDecision.Denied {
record Allowed(String reason) implements AccessDecision {
}
record Denied(String reason, DenialVisibility visibility) implements AccessDecision {
}
}
Der Grund der Entscheidung ist fuer Audit wichtig. Die Sichtbarkeit ist fuer sichere Fehlerantworten wichtig. Nicht jede Ablehnung sollte dem Client gleich viel verraten.
Security-Fehler sind nicht nur HTTP-Statuscodes. Sie sind ein Teil des Sicherheitsdesigns.
| Status | Bedeutung | Typischer Fall | Vorsicht |
|---|---|---|---|
| 401 | nicht authentifiziert | Token fehlt, ungueltig oder abgelaufen | keine Signaturdetails leaken |
| 403 | authentifiziert, aber nicht erlaubt | Benutzer ist bekannt, Permission fehlt | keine internen Policy-Namen leaken |
| 404 | Ressource nicht sichtbar oder nicht vorhanden | fremder Tenant, fremde private Ressource | bewusst als Schutz gegen Enumeration |
return new ProblemDetails(
403,
"https://errors.example.com/forbidden",
"Forbidden",
"You are not allowed to perform this action.",
correlationId,
Map.of());
Die Antwort ist stabil, knapp und sicher. Interne Gruende bleiben im Audit, nicht in der API-Antwort.
Audit ist nicht nur Logging. Ein Audit-Ereignis muss spaeter beantworten koennen: Wer hat was auf welcher Ressource versucht, wann, mit welcher Entscheidung und welcher Correlation ID?
public record AuditEvent(
Instant occurredAt,
String correlationId,
String actor,
String action,
String resource,
String decision,
String reason,
String remoteAddress
) {
}
In Enterprise-Systemen sprechen Services miteinander. Ein User-Token ist nicht automatisch ein Service-Token. Ein Payment-Service, der eine Zahlung autorisiert, braucht eigene Identitaet, eigene Scopes und eigene Auditierbarkeit.
| Variante | Zweck | Risiko |
|---|---|---|
| User Token weiterreichen | User-Kontext bleibt erhalten | nicht jeder Service darf alles mit User-Rechten tun |
| On-behalf-of | Service handelt kontrolliert fuer Benutzer | komplexere Token- und Scope-Regeln |
| Client Credentials | Service handelt als Service | kein User-Kontext, Audit braucht Service-Actor |
| mTLS | technische Service-Identitaet | ersetzt nicht fachliche Autorisierung |
public final class ServiceTokenPolicy implements AccessPolicy<String> {
private final String requiredScope;
public AccessDecision evaluate(SecurityContext context, String action) {
return context.principal()
.filter(principal -> principal.hasScope(requiredScope))
.map(principal -> AccessDecision.allowed("service scope grants " + requiredScope))
.orElseGet(() -> AccessDecision.denied("missing service scope " + requiredScope,
DenialVisibility.SHOW_FORBIDDEN));
}
}
Scopes beschreiben technische API-Rechte. Sie sollten nicht blind mit fachlichen Permissions gleichgesetzt werden.
Aus Kapitel kommt der API-Vertrag. Kapitel fuegt die Sicherheitsentscheidung hinzu. Der Request-Flow wird damit:
HTTP Request
-> Correlation ID
-> Token aus Authorization Header
-> Token validieren
-> SecurityContext erzeugen
-> DTO validieren
-> Ressource laden
-> Policy pruefen
-> Use Case ausfuehren
-> Audit schreiben
-> sichere Response erzeugen
Wichtig ist die Reihenfolge. Manchmal muss eine Ressource geladen werden, bevor Ownership geprueft werden kann. Dabei darf aber kein Datenleck entstehen. Repository-Zugriff, Policy und Fehlerabbildung muessen gemeinsam geplant werden.
if (role.equals("ADMIN") || role.equals("SUPER_USER")) { ... }
Problem: schwer testbar, schwer refactorbar, keine zentrale Permission-Sicht.
Wenn Messaging-Consumer oder Batchjobs dieselbe Use-Case-Logik ohne Controller ausfuehren, werden Regeln umgangen.
Ein Angreifer kann IDs raten und durch 403 erkennen, dass eine Ressource existiert.
Claims muessen zu Issuer, Audience, Ablaufzeit, Tenant und Signatur passen. Sonst akzeptiert das System fremde oder abgelaufene Tokens.
Auch verweigerte Zugriffe sind sicherheitsrelevant.
Security braucht eigene Tests. Controller-Tests allein sind zu grob. Gute Security-Tests pruefen Policies, Fehlerabbildung, Audit und Service-Scopes.
Im Lab enthalten:
- Owner darf eigene Bestellung lesen
- fremder Kunde bekommt NotFound
- Support darf beliebige Bestellung lesen
- ungueltiges Token wird 401
- abgelaufenes Token wird 401
- Allow und Deny erzeugen Audit Events
- Service-Scope erlaubt Payment-Aktion
SecurityContext stranger = f.context("customer-2", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
assertThrows(NotFoundException.class,
() -> f.query.getOrder(stranger, new OrderId("ORD-1")),
"foreign customer hidden as not found");
Dieser Test ist fachlich wichtiger als ein reiner HTTP-Test. Er prueft die echte Regel: Fremde Ressource wird verborgen.
[ ] Gibt es eine klare Trennung zwischen Authentifizierung und Autorisierung?
[ ] Sind Rollen, Permissions, Scopes und Ownership sauber getrennt?
[ ] Sind Tenant-Grenzen testbar?
[ ] Gibt es Policies statt verstreuter if-Abfragen?
[ ] Werden Deny-Entscheidungen auditiert?
[ ] Leaken Fehlerantworten keine internen Details?
[ ] Werden Service-Identitaeten getrennt von User-Identitaeten behandelt?
[ ] Gibt es Tests fuer Owner, Support, Billing, Service und fremde Tenants?
[ ] Ist die Security-Logik ausserhalb des Controllers wiederverwendbar?
Der wichtigste Pfad im Lab ist:
SecurityContextFactory
-> SignedTokenService.validate
-> OrderQueryService.getOrder
-> AuthorizationService.require
-> OrderReadPolicy.evaluate
-> AuditSink.append
-> SecurityExceptionMapper.toProblem
Die Architekturabsicht ist bewusst: Token-Technik ist am Rand, Policy ist im Application-Bereich, Audit ist ein Port, Fehlerantworten werden gemappt.
OrderCancelPolicy: Kunde darf eigene
Bestellung nur stornieren, wenn Status NEW oder
CONFIRMED ist.TenantPolicy hinzu, die Tenant-Pruefungen
zentralisiert.clientId fuer Service-Accounts.TokenValidationException-Details in Problem Details
auftauchen.SUPPORT_READ_ONLY und
SUPPORT_MANAGER als getrennte Rollen.Kapitel vertieft Security nicht als Framework-Konfiguration, sondern als Architekturentscheidung. Authentifizierung liefert Identitaet. Autorisierung prueft konkrete Aktionen auf konkrete Ressourcen. Policies machen Regeln sichtbar. Audit macht Entscheidungen rekonstruierbar. Sichere Fehlerantworten verhindern Informationslecks. Service-to-Service-Security trennt technische Dienstidentitaet von Benutzeridentitaet.