Harden access control and restore customer admin pages

This commit is contained in:
2026-03-13 08:52:04 +01:00
parent eb699666d9
commit c7362a553b
14 changed files with 307 additions and 79 deletions

View File

@@ -27,6 +27,7 @@ public record Sample(
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt, LocalDateTime updatedAt,
LocalDateTime completedAt, LocalDateTime completedAt,
String ownerAccountId,
String createdByUserCode, String createdByUserCode,
String createdByDisplayName String createdByDisplayName
) { ) {

View File

@@ -22,6 +22,9 @@ public class AuthTokenService {
@Value("${muh.security.token-secret}") String secret, @Value("${muh.security.token-secret}") String secret,
@Value("${muh.security.token-validity-hours:12}") long validityHours @Value("${muh.security.token-validity-hours:12}") long validityHours
) { ) {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("MUH_TOKEN_SECRET muss gesetzt sein");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8); this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.validitySeconds = Math.max(1, validityHours) * 3600L; this.validitySeconds = Math.max(1, validityHours) * 3600L;
} }

View File

@@ -0,0 +1,52 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Service
public class AuthorizationService {
private final AppUserRepository appUserRepository;
public AuthorizationService(AppUserRepository appUserRepository) {
this.appUserRepository = appUserRepository;
}
public AppUser requireActiveUser(String actorId, String message) {
return appUserRepository.findById(requireText(actorId, message))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
}
public void requireAdmin(String actorId, String message) {
AppUser actor = requireActiveUser(actorId, message);
if (!isAdmin(actor)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
}
public boolean isAdmin(AppUser user) {
return user.role() == UserRole.ADMIN;
}
public String accountId(AppUser user) {
if (user.accountId() == null || user.accountId().isBlank()) {
if (user.id() == null || user.id().isBlank()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Benutzerkonto ungueltig");
}
return user.id().trim();
}
return user.accountId().trim();
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
return value.trim();
}
}

View File

@@ -14,6 +14,7 @@ import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository; import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository; import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import de.svencarstensen.muh.security.AuthTokenService; import de.svencarstensen.muh.security.AuthTokenService;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
@@ -66,6 +67,7 @@ public class CatalogService {
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService; private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService( public CatalogService(
@@ -75,7 +77,8 @@ public class CatalogService {
AntibioticCatalogRepository antibioticRepository, AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository, AppUserRepository appUserRepository,
MongoTemplate mongoTemplate, MongoTemplate mongoTemplate,
AuthTokenService authTokenService AuthTokenService authTokenService,
AuthorizationService authorizationService
) { ) {
this.farmerRepository = farmerRepository; this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository; this.medicationRepository = medicationRepository;
@@ -84,6 +87,7 @@ public class CatalogService {
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService; this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
} }
public ActiveCatalogSummary activeCatalogSummary() { public ActiveCatalogSummary activeCatalogSummary() {
@@ -96,7 +100,8 @@ public class CatalogService {
); );
} }
public AdministrationOverview administrationOverview() { public AdministrationOverview administrationOverview(String actorId) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows()); return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
} }
@@ -128,7 +133,8 @@ public class CatalogService {
.toList(); .toList();
} }
public List<FarmerRow> saveFarmers(List<FarmerMutation> mutations) { public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (FarmerMutation mutation : mutations) { for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;
@@ -191,7 +197,8 @@ public class CatalogService {
return listFarmerRows(); return listFarmerRows();
} }
public List<MedicationRow> saveMedications(List<MedicationMutation> mutations) { public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (MedicationMutation mutation : mutations) { for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) { if (isBlank(mutation.name()) || mutation.category() == null) {
continue; continue;
@@ -254,7 +261,8 @@ public class CatalogService {
return listMedicationRows(); return listMedicationRows();
} }
public List<PathogenRow> savePathogens(List<PathogenMutation> mutations) { public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (PathogenMutation mutation : mutations) { for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) { if (isBlank(mutation.name()) || mutation.kind() == null) {
continue; continue;
@@ -322,7 +330,8 @@ public class CatalogService {
return listPathogenRows(); return listPathogenRows();
} }
public List<AntibioticRow> saveAntibiotics(List<AntibioticMutation> mutations) { public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (AntibioticMutation mutation : mutations) { for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;

View File

@@ -36,20 +36,20 @@ public class PortalService {
List<PortalSampleRow> sampleRows; List<PortalSampleRow> sampleRows;
if (sampleNumber != null) { if (sampleNumber != null) {
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(sampleNumber))); sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(actorId, sampleNumber)));
} else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) { } else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) {
sampleRows = sampleService.samplesByFarmerBusinessKey(farmerBusinessKey).stream() sampleRows = sampleService.samplesByFarmerBusinessKey(actorId, farmerBusinessKey).stream()
.filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery)) .filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery))
.map(this::toPortalRow) .map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed()) .sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
.toList(); .toList();
} else if (date != null) { } else if (date != null) {
sampleRows = sampleService.samplesByDate(date).stream() sampleRows = sampleService.samplesByDate(actorId, date).stream()
.map(this::toPortalRow) .map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder()))) .sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.toList(); .toList();
} else { } else {
sampleRows = sampleService.completedSamples().stream() sampleRows = sampleService.completedSamples(actorId).stream()
.limit(25) .limit(25)
.map(this::toPortalRow) .map(this::toPortalRow)
.toList(); .toList();
@@ -58,13 +58,13 @@ public class PortalService {
return new PortalSnapshot( return new PortalSnapshot(
matchingFarmers, matchingFarmers,
sampleRows, sampleRows,
reportService.reportCandidates(), reportService.reportCandidates(actorId),
includeUsers ? catalogService.listUsers(actorId) : List.of() includeUsers ? catalogService.listUsers(actorId) : List.of()
); );
} }
public List<PortalSampleRow> searchSamplesByCreatedDate(LocalDate date) { public List<PortalSampleRow> searchSamplesByCreatedDate(String actorId, LocalDate date) {
return sampleService.samplesByCreatedDate(date).stream() return sampleService.samplesByCreatedDate(actorId, date).stream()
.map(this::toPortalRow) .map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed()) .sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
.toList(); .toList();

View File

@@ -53,8 +53,8 @@ public class ReportService {
this.reportMailTemplate = loadTemplate(reportMailTemplateResource); this.reportMailTemplate = loadTemplate(reportMailTemplateResource);
} }
public List<ReportCandidate> reportCandidates() { public List<ReportCandidate> reportCandidates(String actorId) {
return sampleService.completedSamples().stream() return sampleService.completedSamples(actorId).stream()
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank()) .filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
.map(this::toCandidate) .map(this::toCandidate)
.toList(); .toList();
@@ -66,7 +66,7 @@ public class ReportService {
List<ReportCandidate> skipped = new ArrayList<>(); List<ReportCandidate> skipped = new ArrayList<>();
for (String sampleId : sampleIds) { for (String sampleId : sampleIds) {
Sample sample = sampleService.loadSampleEntity(sampleId); Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) { if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) {
skipped.add(toCandidate(sample)); skipped.add(toCandidate(sample));
continue; continue;
@@ -82,12 +82,13 @@ public class ReportService {
return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null); return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null);
} }
public byte[] reportPdf(String sampleId) { public byte[] reportPdf(String actorId, String sampleId) {
return buildPdf(sampleService.loadSampleEntity(sampleId)); return buildPdf(sampleService.loadSampleEntity(actorId, sampleId));
} }
public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) { public SampleService.SampleDetail toggleReportBlocked(String actorId, String sampleId, boolean blocked) {
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id()); Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
return sampleService.getSample(actorId, sampleService.toggleReportBlocked(sample.id(), blocked).id());
} }
private void sendMail(Sample sample, byte[] pdf, String customerSignature) { private void sendMail(Sample sample, byte[] pdf, String customerSignature) {

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.service; package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibiogramEntry; import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem; import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind; import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterAntibiogram; import de.svencarstensen.muh.domain.QuarterAntibiogram;
@@ -13,6 +14,8 @@ import de.svencarstensen.muh.domain.SamplingMode;
import de.svencarstensen.muh.domain.SensitivityResult; import de.svencarstensen.muh.domain.SensitivityResult;
import de.svencarstensen.muh.domain.TherapyRecommendation; import de.svencarstensen.muh.domain.TherapyRecommendation;
import de.svencarstensen.muh.repository.SampleRepository; import de.svencarstensen.muh.repository.SampleRepository;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -25,33 +28,54 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service @Service
public class SampleService { public class SampleService {
private final SampleRepository sampleRepository; private final SampleRepository sampleRepository;
private final CatalogService catalogService; private final CatalogService catalogService;
private final AppUserRepository appUserRepository;
private final AuthorizationService authorizationService;
public SampleService(SampleRepository sampleRepository, CatalogService catalogService) { public SampleService(
SampleRepository sampleRepository,
CatalogService catalogService,
AppUserRepository appUserRepository,
AuthorizationService authorizationService
) {
this.sampleRepository = sampleRepository; this.sampleRepository = sampleRepository;
this.catalogService = catalogService; this.catalogService = catalogService;
this.appUserRepository = appUserRepository;
this.authorizationService = authorizationService;
} }
public DashboardOverview dashboardOverview() { public DashboardOverview dashboardOverview(String actorId) {
List<SampleSummary> recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream() AppUser actor = requireActor(actorId);
List<Sample> accessibleSamples = accessibleSamples(actor);
List<SampleSummary> recent = accessibleSamples.stream()
.sorted(Comparator.comparing(Sample::updatedAt).reversed())
.limit(12)
.map(this::toSummary) .map(this::toSummary)
.toList(); .toList();
long openCount = sampleRepository.findAll().stream().filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED).count(); long openCount = accessibleSamples.stream()
.filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED)
.count();
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc( long completedToday = accessibleSamples.stream()
today.atStartOfDay(), .filter(sample -> sample.completedAt() != null)
today.plusDays(1).atStartOfDay() .filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
).size(); .filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
.count();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent); return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent);
} }
public LookupResult lookup(long sampleNumber) { public LookupResult lookup(String actorId, long sampleNumber) {
AppUser actor = requireActor(actorId);
return sampleRepository.findBySampleNumber(sampleNumber) return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(sample -> new LookupResult( .map(sample -> new LookupResult(
true, true,
"Probe gefunden", "Probe gefunden",
@@ -62,17 +86,20 @@ public class SampleService {
.orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null)); .orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null));
} }
public SampleDetail getSample(String id) { public SampleDetail getSample(String actorId, String id) {
return toDetail(loadSample(id)); return toDetail(loadAccessibleSample(actorId, id));
} }
public SampleDetail getSampleByNumber(long sampleNumber) { public SampleDetail getSampleByNumber(String actorId, long sampleNumber) {
AppUser actor = requireActor(actorId);
return sampleRepository.findBySampleNumber(sampleNumber) return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(this::toDetail) .map(this::toDetail)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
} }
public SampleDetail createSample(RegistrationRequest request) { public SampleDetail createSample(String actorId, RegistrationRequest request) {
AppUser actor = requireActor(actorId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
@@ -99,6 +126,7 @@ public class SampleService {
now, now,
now, now,
null, null,
authorizationService.accountId(actor),
request.userCode(), request.userCode(),
request.userDisplayName() request.userDisplayName()
); );
@@ -106,8 +134,8 @@ public class SampleService {
return toDetail(sampleRepository.save(sample)); return toDetail(sampleRepository.save(sample));
} }
public SampleDetail saveRegistration(String id, RegistrationRequest request) { public SampleDetail saveRegistration(String actorId, String id, RegistrationRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditRegistration(existing)) { if (!SampleWorkflowRules.canEditRegistration(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
} }
@@ -137,6 +165,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -144,8 +173,8 @@ public class SampleService {
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) { public SampleDetail saveAnamnesis(String actorId, String id, AnamnesisRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAnamnesis(existing)) { if (!SampleWorkflowRules.canEditAnamnesis(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden");
} }
@@ -203,14 +232,15 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) { public SampleDetail saveAntibiogram(String actorId, String id, AntibiogramRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAntibiogram(existing)) { if (!SampleWorkflowRules.canEditAntibiogram(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
} }
@@ -293,14 +323,15 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveTherapy(String id, TherapyRequest request) { public SampleDetail saveTherapy(String actorId, String id, TherapyRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) { if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation(); TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null TherapyRecommendation updated = previous == null
@@ -341,6 +372,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
))); )));
@@ -388,6 +420,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
LocalDateTime.now(), LocalDateTime.now(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -416,6 +449,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -443,25 +477,41 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
} }
public List<Sample> completedSamples() { public List<Sample> completedSamples(String actorId) {
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc(); return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> sample.completedAt() != null)
.sorted(Comparator.comparing(Sample::completedAt).reversed())
.toList();
} }
public List<Sample> samplesByFarmerBusinessKey(String businessKey) { public List<Sample> samplesByFarmerBusinessKey(String actorId, String businessKey) {
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey); return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> Objects.equals(sample.farmerBusinessKey(), businessKey))
.sorted(Comparator.comparing(Sample::createdAt).reversed())
.toList();
} }
public List<Sample> samplesByCreatedDate(LocalDate date) { public List<Sample> samplesByCreatedDate(String actorId, LocalDate date) {
return sampleRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> !sample.createdAt().isBefore(date.atStartOfDay()))
.filter(sample -> sample.createdAt().isBefore(date.plusDays(1).atStartOfDay()))
.sorted(Comparator.comparing(Sample::createdAt).reversed())
.toList();
} }
public List<Sample> samplesByDate(LocalDate date) { public List<Sample> samplesByDate(String actorId, LocalDate date) {
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> sample.completedAt() != null)
.filter(sample -> !sample.completedAt().isBefore(date.atStartOfDay()))
.filter(sample -> sample.completedAt().isBefore(date.plusDays(1).atStartOfDay()))
.sorted(Comparator.comparing(Sample::completedAt).reversed())
.toList();
} }
public long nextSampleNumber() { public long nextSampleNumber() {
@@ -470,15 +520,119 @@ public class SampleService {
.orElse(100001L); .orElse(100001L);
} }
public Sample loadSampleEntity(String id) { public Sample loadSampleEntity(String actorId, String id) {
return loadSample(id); return loadAccessibleSample(actorId, id);
}
private AppUser requireActor(String actorId) {
ensureSampleOwnershipMigration();
return authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
}
private List<Sample> accessibleSamples(AppUser actor) {
List<Sample> samples = sampleRepository.findAll();
if (authorizationService.isAdmin(actor)) {
return samples;
}
String accountId = authorizationService.accountId(actor);
return samples.stream()
.filter(sample -> Objects.equals(sample.ownerAccountId(), accountId))
.toList();
}
private boolean canAccess(AppUser actor, Sample sample) {
return authorizationService.isAdmin(actor)
|| Objects.equals(sample.ownerAccountId(), authorizationService.accountId(actor));
}
private Sample loadAccessibleSample(String actorId, String id) {
AppUser actor = requireActor(actorId);
Sample sample = loadSample(id);
if (!canAccess(actor, sample)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden");
}
return sample;
} }
private Sample loadSample(String id) { private Sample loadSample(String id) {
ensureSampleOwnershipMigration();
return sampleRepository.findById(Objects.requireNonNull(id)) return sampleRepository.findById(Objects.requireNonNull(id))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
} }
private void ensureSampleOwnershipMigration() {
List<Sample> samples = sampleRepository.findAll().stream()
.filter(sample -> sample.ownerAccountId() == null || sample.ownerAccountId().isBlank())
.toList();
if (samples.isEmpty()) {
return;
}
List<AppUser> users = appUserRepository.findAll().stream()
.filter(AppUser::active)
.toList();
Map<String, List<AppUser>> usersByDisplayName = users.stream()
.filter(user -> user.displayName() != null && !user.displayName().isBlank())
.collect(Collectors.groupingBy(user -> user.displayName().trim().toLowerCase()));
List<String> primaryCustomerAccounts = users.stream()
.filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN)
.filter(user -> Boolean.TRUE.equals(user.primaryUser()))
.map(authorizationService::accountId)
.distinct()
.toList();
String fallbackAccountId = primaryCustomerAccounts.size() == 1 ? primaryCustomerAccounts.get(0) : null;
for (Sample sample : samples) {
String resolvedAccountId = resolveSampleOwnerAccountId(sample, usersByDisplayName, fallbackAccountId);
if (resolvedAccountId == null) {
continue;
}
sampleRepository.save(new Sample(
sample.id(),
sample.sampleNumber(),
sample.farmerBusinessKey(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowNumber(),
sample.cowName(),
sample.sampleKind(),
sample.samplingMode(),
sample.currentStep(),
sample.quarters(),
sample.antibiograms(),
sample.therapyRecommendation(),
sample.reportSent(),
sample.reportBlocked(),
sample.reportSentAt(),
sample.createdAt(),
sample.updatedAt(),
sample.completedAt(),
resolvedAccountId,
sample.createdByUserCode(),
sample.createdByDisplayName()
));
}
}
private String resolveSampleOwnerAccountId(
Sample sample,
Map<String, List<AppUser>> usersByDisplayName,
String fallbackAccountId
) {
if (sample.createdByDisplayName() != null && !sample.createdByDisplayName().isBlank()) {
List<String> matchingAccounts = usersByDisplayName
.getOrDefault(sample.createdByDisplayName().trim().toLowerCase(), List.of())
.stream()
.map(authorizationService::accountId)
.distinct()
.toList();
if (matchingAccounts.size() == 1) {
return matchingAccounts.get(0);
}
}
return fallbackAccountId;
}
private SampleSummary toSummary(Sample sample) { private SampleSummary toSummary(Sample sample) {
return new SampleSummary( return new SampleSummary(
sample.id(), sample.id(),

View File

@@ -32,27 +32,27 @@ public class CatalogController {
@GetMapping("/admin") @GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() { public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview(); return catalogService.administrationOverview(securitySupport.currentUser().id());
} }
@PostMapping("/admin/farmers") @PostMapping("/admin/farmers")
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) { public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(mutations); return catalogService.saveFarmers(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/medications") @PostMapping("/admin/medications")
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) { public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(mutations); return catalogService.saveMedications(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/pathogens") @PostMapping("/admin/pathogens")
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) { public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(mutations); return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/antibiotics") @PostMapping("/admin/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) { public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(mutations); return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
} }
@GetMapping("/portal/users") @GetMapping("/portal/users")

View File

@@ -58,12 +58,12 @@ public class PortalController {
@GetMapping("/reports") @GetMapping("/reports")
public List<ReportService.ReportCandidate> reports() { public List<ReportService.ReportCandidate> reports() {
return reportService.reportCandidates(); return reportService.reportCandidates(securitySupport.currentUser().id());
} }
@GetMapping("/search/by-date") @GetMapping("/search/by-date")
public List<PortalService.PortalSampleRow> searchByDate(@RequestParam LocalDate date) { public List<PortalService.PortalSampleRow> searchByDate(@RequestParam LocalDate date) {
return portalService.searchSamplesByCreatedDate(date); return portalService.searchSamplesByCreatedDate(securitySupport.currentUser().id(), date);
} }
@PostMapping("/reports/send") @PostMapping("/reports/send")
@@ -73,12 +73,12 @@ public class PortalController {
@PatchMapping("/reports/{sampleId}/block") @PatchMapping("/reports/{sampleId}/block")
public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) { public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) {
return reportService.toggleReportBlocked(sampleId, request.blocked()); return reportService.toggleReportBlocked(securitySupport.currentUser().id(), sampleId, request.blocked());
} }
@GetMapping("/reports/{sampleId}/pdf") @GetMapping("/reports/{sampleId}/pdf")
public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) { public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) {
byte[] pdf = reportService.reportPdf(sampleId); byte[] pdf = reportService.reportPdf(securitySupport.currentUser().id(), sampleId);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF)) .contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF))
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()

View File

@@ -25,28 +25,28 @@ public class SampleController {
@GetMapping("/dashboard") @GetMapping("/dashboard")
public SampleService.DashboardOverview dashboardOverview() { public SampleService.DashboardOverview dashboardOverview() {
return sampleService.dashboardOverview(); return sampleService.dashboardOverview(securitySupport.currentUser().id());
} }
@GetMapping("/dashboard/lookup/{sampleNumber}") @GetMapping("/dashboard/lookup/{sampleNumber}")
public SampleService.LookupResult lookup(@PathVariable long sampleNumber) { public SampleService.LookupResult lookup(@PathVariable long sampleNumber) {
return sampleService.lookup(sampleNumber); return sampleService.lookup(securitySupport.currentUser().id(), sampleNumber);
} }
@GetMapping("/samples/{id}") @GetMapping("/samples/{id}")
public SampleService.SampleDetail sample(@PathVariable String id) { public SampleService.SampleDetail sample(@PathVariable String id) {
return sampleService.getSample(id); return sampleService.getSample(securitySupport.currentUser().id(), id);
} }
@GetMapping("/samples/by-number/{sampleNumber}") @GetMapping("/samples/by-number/{sampleNumber}")
public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) { public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) {
return sampleService.getSampleByNumber(sampleNumber); return sampleService.getSampleByNumber(securitySupport.currentUser().id(), sampleNumber);
} }
@PostMapping("/samples") @PostMapping("/samples")
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) { public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) {
AuthenticatedUser user = securitySupport.currentUser(); AuthenticatedUser user = securitySupport.currentUser();
return sampleService.createSample(new SampleService.RegistrationRequest( return sampleService.createSample(user.id(), new SampleService.RegistrationRequest(
request.farmerBusinessKey(), request.farmerBusinessKey(),
request.cowNumber(), request.cowNumber(),
request.cowName(), request.cowName(),
@@ -60,22 +60,22 @@ public class SampleController {
@PutMapping("/samples/{id}/registration") @PutMapping("/samples/{id}/registration")
public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) { public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) {
return sampleService.saveRegistration(id, request); return sampleService.saveRegistration(securitySupport.currentUser().id(), id, request);
} }
@PutMapping("/samples/{id}/anamnesis") @PutMapping("/samples/{id}/anamnesis")
public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) { public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) {
return sampleService.saveAnamnesis(id, request); return sampleService.saveAnamnesis(securitySupport.currentUser().id(), id, request);
} }
@PutMapping("/samples/{id}/antibiogram") @PutMapping("/samples/{id}/antibiogram")
public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) { public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) {
return sampleService.saveAntibiogram(id, request); return sampleService.saveAntibiogram(securitySupport.currentUser().id(), id, request);
} }
@PutMapping("/samples/{id}/therapy") @PutMapping("/samples/{id}/therapy")
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) { public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) {
return sampleService.saveTherapy(id, request); return sampleService.saveTherapy(securitySupport.currentUser().id(), id, request);
} }
private String deriveUserLabel(String displayName) { private String deriveUserLabel(String displayName) {

View File

@@ -34,7 +34,7 @@ muh:
cors: cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000} allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
security: security:
token-secret: ${MUH_TOKEN_SECRET:change-me-in-production} token-secret: ${MUH_TOKEN_SECRET:}
token-validity-hours: ${MUH_TOKEN_VALIDITY_HOURS:12} token-validity-hours: ${MUH_TOKEN_VALIDITY_HOURS:12}
mongodb: mongodb:
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh} url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}

View File

@@ -16,6 +16,7 @@ import UserManagementPage from "./pages/UserManagementPage";
function ProtectedRoutes() { function ProtectedRoutes() {
const { user, ready } = useSession(); const { user, ready } = useSession();
const isAdmin = user?.role === "ADMIN";
if (!ready) { if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>; return <div className="empty-state">Sitzung wird geladen ...</div>;
@@ -34,7 +35,7 @@ function ProtectedRoutes() {
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} /> <Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} /> <Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} /> <Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
<Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} /> <Route path="/admin" element={<Navigate to={isAdmin ? "/admin/landwirte" : "/admin/benutzer"} replace />} />
<Route path="/admin/benutzer" element={<UserManagementPage />} /> <Route path="/admin/benutzer" element={<UserManagementPage />} />
<Route path="/admin/landwirte" element={<AdministrationPage />} /> <Route path="/admin/landwirte" element={<AdministrationPage />} />
<Route path="/admin/medikamente" element={<AdministrationPage />} /> <Route path="/admin/medikamente" element={<AdministrationPage />} />

View File

@@ -21,19 +21,19 @@ function resolvePageTitle(pathname: string) {
return "Probe bearbeiten"; return "Probe bearbeiten";
} }
if (pathname.startsWith("/admin/landwirte")) { if (pathname.startsWith("/admin/landwirte")) {
return "Verwaltung | Landwirte"; return "Die Verwaltung der Landwirte";
} }
if (pathname.startsWith("/admin/benutzer")) { if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer"; return "Verwaltung | Benutzer";
} }
if (pathname.startsWith("/admin/medikamente")) { if (pathname.startsWith("/admin/medikamente")) {
return "Verwaltung | Medikamente"; return "Die Verwaltung der Medikamente";
} }
if (pathname.startsWith("/admin/erreger")) { if (pathname.startsWith("/admin/erreger")) {
return "Verwaltung | Erreger"; return "Die Verwaltung der Erreger";
} }
if (pathname.startsWith("/admin/antibiogramm")) { if (pathname.startsWith("/admin/antibiogramm")) {
return "Verwaltung | Antibiogramm"; return "Die Verwaltung der Antibiogramme";
} }
if (pathname.startsWith("/search/landwirt")) { if (pathname.startsWith("/search/landwirt")) {
return "Suche | Landwirt"; return "Suche | Landwirt";
@@ -79,9 +79,6 @@ export default function AppShell() {
<div className="nav-group"> <div className="nav-group">
<div className="nav-group__label">Verwaltung</div> <div className="nav-group__label">Verwaltung</div>
<div className="nav-subnav"> <div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Benutzer
</NavLink>
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}> <NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirte Landwirte
</NavLink> </NavLink>
@@ -94,6 +91,9 @@ export default function AppShell() {
<NavLink to="/admin/antibiogramm" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}> <NavLink to="/admin/antibiogramm" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Antibiogramm Antibiogramm
</NavLink> </NavLink>
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Benutzer
</NavLink>
</div> </div>
</div> </div>

View File

@@ -26,6 +26,13 @@ const DATASET_LABELS: Record<DatasetKey, string> = {
antibiotics: "Antibiogramm", antibiotics: "Antibiogramm",
}; };
const DATASET_TITLES: Record<DatasetKey, string> = {
farmers: "Die Verwaltung der Landwirte",
medications: "Die Verwaltung der Medikamente",
pathogens: "Die Verwaltung der Erreger",
antibiotics: "Die Verwaltung der Antibiogramme",
};
function normalizeOverview(overview: AdministrationOverview): DatasetsState { function normalizeOverview(overview: AdministrationOverview): DatasetsState {
return { return {
farmers: overview.farmers.map((entry) => ({ farmers: overview.farmers.map((entry) => ({
@@ -216,7 +223,7 @@ export default function AdministrationPage() {
<section className="section-card section-card--hero"> <section className="section-card section-card--hero">
<div> <div>
<p className="eyebrow">Verwaltung</p> <p className="eyebrow">Verwaltung</p>
<h3>Stammdaten direkt pflegen</h3> <h3>{DATASET_TITLES[selectedDataset]}</h3>
<p className="muted-text"> <p className="muted-text">
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
Satz inaktiv sichtbar. Satz inaktiv sichtbar.