feat: bulk-reveal encrypted Allergien on member list (admin-only) (#198)
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 35s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 4s

This commit was merged in pull request #198.
This commit is contained in:
2026-04-17 21:58:15 +02:00
parent bcf92aeaea
commit 5a3e1c9ef2
9 changed files with 259 additions and 8 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<name>Mitgliederverwaltung</name>
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
<version>0.2.10</version>
<version>0.2.11</version>
<licence>agpl</licence>
<author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace>
+3
View File
@@ -13,6 +13,9 @@ return [
// ── Member archive (soft-deleted, admin-only) ───────────────
['name' => 'member#archive', 'url' => '/api/v1/members/archive', 'verb' => 'GET'],
// ── Admin: reveal encrypted Allergien (audited) ──────────────
['name' => 'member#revealAllergies', 'url' => '/api/v1/members/allergien/reveal', 'verb' => 'POST'],
// ── Member CRUD ──────────────────────────────────────────────
['name' => 'member#index', 'url' => '/api/v1/members', 'verb' => 'GET'],
['name' => 'member#show', 'url' => '/api/v1/members/{id}', 'verb' => 'GET'],
+35
View File
@@ -282,6 +282,41 @@ class MemberController extends ApiController {
}
}
// ── Admin-only: bulk-reveal encrypted Allergien ─────────────────
/**
* Reveal decrypted Allergien for every non-deleted member.
*
* POST /api/v1/members/allergien/reveal
*
* Admin-only (enforced by AuthorizationMiddleware).
* Each reveal is audited. Response is not cached; clients are expected
* to drop the plaintext from memory after use.
*
* @return JSONResponse { data: { [memberId]: plaintext|null } }
*/
public function revealAllergies(): JSONResponse {
try {
$user = $this->userSession->getUser();
$userId = $user !== null ? $user->getUID() : 'unknown';
$map = $this->memberService->revealAllAllergies($userId);
$response = new JSONResponse(['data' => $map]);
$response->addHeader('Cache-Control', 'no-store');
return $response;
} catch (\Exception $e) {
$this->logger->error('Allergien reveal fehlgeschlagen', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Allergien konnten nicht entschluesselt werden.'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// ── Address sub-entity endpoints ─────────────────────────────────
/**
@@ -11,6 +11,7 @@ use OCA\Mitgliederverwaltung\Controller\ExportController;
use OCA\Mitgliederverwaltung\Controller\FeeController;
use OCA\Mitgliederverwaltung\Controller\FileController;
use OCA\Mitgliederverwaltung\Controller\ImportController;
use OCA\Mitgliederverwaltung\Controller\MemberController;
use OCA\Mitgliederverwaltung\Controller\MilestoneController;
use OCA\Mitgliederverwaltung\Controller\PageController;
use OCA\Mitgliederverwaltung\Controller\PermissionController;
@@ -43,6 +44,7 @@ class AuthorizationMiddleware extends Middleware {
private const ADMIN_METHODS_EXPORT = ['bundleSensitive'];
private const ADMIN_METHODS_MILESTONE = ['updateSettings'];
private const ADMIN_METHODS_FILE = ['updateSettings'];
private const ADMIN_METHODS_MEMBER = ['revealAllergies', 'archive'];
/** Methods that are read-only regardless of HTTP verb */
private const READ_METHODS = [
@@ -145,6 +147,9 @@ class AuthorizationMiddleware extends Middleware {
if ($controller instanceof FileController) {
return self::ADMIN_METHODS_FILE;
}
if ($controller instanceof MemberController) {
return self::ADMIN_METHODS_MEMBER;
}
return null;
}
+47 -1
View File
@@ -36,6 +36,7 @@ class MemberService {
private StufeHistoryMapper $stufeHistoryMapper;
private AuditService $auditService;
private LoggerInterface $logger;
private ?EncryptionService $encryptionService;
public function __construct(
MemberMapper $memberMapper,
@@ -45,7 +46,8 @@ class MemberService {
FamilyMapper $familyMapper,
StufeHistoryMapper $stufeHistoryMapper,
AuditService $auditService,
LoggerInterface $logger
LoggerInterface $logger,
?EncryptionService $encryptionService = null
) {
$this->memberMapper = $memberMapper;
$this->addressMapper = $addressMapper;
@@ -55,6 +57,50 @@ class MemberService {
$this->stufeHistoryMapper = $stufeHistoryMapper;
$this->auditService = $auditService;
$this->logger = $logger;
$this->encryptionService = $encryptionService;
}
/**
* Reveal decrypted allergien for every non-deleted member.
*
* Caller must be authorized (admin-only enforced by the middleware).
* The caller is audited — field + count, never the content.
*
* @param string $userId ID of the user triggering the reveal (for audit)
* @return array<int,string|null> Map of memberId → plaintext or null
* @throws Exception
* @throws \RuntimeException if EncryptionService is unavailable
*/
public function revealAllAllergies(string $userId): array {
if ($this->encryptionService === null) {
throw new \RuntimeException('EncryptionService ist nicht verfuegbar.');
}
$members = $this->memberMapper->findAll();
$map = [];
$counted = 0;
foreach ($members as $m) {
$ct = $m->getAllergienEncrypted();
if ($ct === null || $ct === '') {
$map[$m->getId()] = null;
continue;
}
$plain = $this->encryptionService->decrypt($ct);
$map[$m->getId()] = $plain;
if ($plain !== null && $plain !== '') {
$counted++;
}
}
$this->logger->info('Allergien-Bulk-Reveal durch Admin', [
'app' => 'mitgliederverwaltung',
'user' => $userId,
'members_total' => count($members),
'members_with_allergien' => $counted,
]);
return $map;
}
// ── Create ───────────────────────────────────────────────────────
+1 -1
View File
@@ -31,7 +31,7 @@ app.use(router)
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
// not via webpack DefinePlugin globals.
app.provide('appName', 'mitgliederverwaltung')
app.provide('appVersion', '0.2.10')
app.provide('appVersion', '0.2.11')
app.mount('#mitgliederverwaltung')
+89 -4
View File
@@ -6,6 +6,19 @@
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<NcButton :type="allergienVisible ? 'warning' : 'secondary'"
:disabled="allergienLoading"
:title="allergienVisible
? 'Allergien-Spalte ausblenden und entschlüsselte Daten verwerfen'
: 'Allergien-Spalte anzeigen (serverseitig entschlüsselt, auditiert)'"
@click="toggleAllergien">
<template #icon>
<NcLoadingIcon v-if="allergienLoading" :size="18" />
<EyeOff v-else-if="allergienVisible" :size="18" />
<Eye v-else :size="18" />
</template>
{{ allergienVisible ? 'Allergien ausblenden' : 'Allergien anzeigen' }}
</NcButton>
<NcButton type="primary"
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
<template #icon>
@@ -145,7 +158,7 @@
<table v-else class="member-list__table">
<thead>
<tr>
<th v-for="col in visibleColumns" :key="col.key"
<th v-for="col in displayColumns" :key="col.key"
class="member-list__th"
:class="{ 'member-list__th--sortable': col.sortable }"
@click="col.sortable && toggleSort(col.sortField || col.key)">
@@ -162,8 +175,9 @@
:key="member.id"
class="member-list__row"
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
<td v-for="col in visibleColumns" :key="col.key"
class="member-list__td">
<td v-for="col in displayColumns" :key="col.key"
class="member-list__td"
:class="col.key === 'allergien' ? 'member-list__td--allergien' : ''">
<span v-if="col.key === 'status'"
:class="'member-list__status member-list__status--' + member.status">
{{ col.render(member) }}
@@ -186,8 +200,12 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import Eye from 'vue-material-design-icons/Eye.vue'
import EyeOff from 'vue-material-design-icons/EyeOff.vue'
import { useMembersStore } from '../stores/members.js'
import { useStufenStore } from '../stores/stufen.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
@@ -288,6 +306,72 @@ const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
['name', 'stufe', 'status', 'geburtsdatum', 'alter', 'rolle'],
)
// ── Allergien reveal (admin-only, audited) ──
// State is kept local and NEVER persisted to localStorage or the Pinia
// store. Toggling off wipes the plaintext map so it cannot be recovered
// from the page.
const allergienVisible = ref(false)
const allergienLoading = ref(false)
const allergienMap = ref(null) // { [memberId]: plaintext | null } | null
const displayColumns = computed(() => {
if (!allergienVisible.value) return visibleColumns.value
return [
...visibleColumns.value,
{
key: 'allergien',
label: 'Allergien',
sortable: false,
render: (m) => {
if (allergienMap.value === null) return '\u2026'
const val = allergienMap.value[m.id]
if (val === null || val === undefined || val === '') return '\u2014'
return val
},
},
]
})
async function toggleAllergien() {
if (allergienVisible.value) {
discardAllergien()
return
}
if (!confirm('Allergien aller Mitglieder entschlüsseln und anzeigen?\n\nSensible medizinische Daten werden serverseitig entschlüsselt und einmalig angezeigt. Der Zugriff wird im Audit-Log protokolliert.')) {
return
}
allergienLoading.value = true
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members/allergien/reveal')
const response = await axios.post(url, {}, {
headers: { 'Cache-Control': 'no-store' },
})
allergienMap.value = response.data?.data || {}
allergienVisible.value = true
} catch (err) {
const msg = err.response?.data?.error || err.message || 'Entschlüsselung fehlgeschlagen'
alert('Allergien konnten nicht geladen werden: ' + msg)
} finally {
allergienLoading.value = false
}
}
function discardAllergien() {
// Overwrite every stored value before dropping the ref so the
// underlying strings are no longer reachable via Vue's reactive proxy.
if (allergienMap.value) {
for (const k of Object.keys(allergienMap.value)) {
allergienMap.value[k] = null
}
}
allergienMap.value = null
allergienVisible.value = false
}
onBeforeUnmount(() => {
discardAllergien()
})
// ── Filters ──
const statusFilters = [
@@ -419,6 +503,7 @@ function reload() {
.member-list__row { cursor: pointer; transition: background-color 0.15s ease; }
.member-list__row:hover { background-color: var(--color-background-hover); }
.member-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
.member-list__td--allergien { color: var(--color-warning-text, #b86e00); font-weight: 500; max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.member-list__status { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
.member-list__status--aktiv { background-color: var(--color-success); color: white; }
.member-list__status--inaktiv { background-color: var(--color-warning); color: white; }
+77
View File
@@ -16,6 +16,7 @@ use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -681,6 +682,82 @@ class MemberServiceTest extends TestCase {
$this->service->update(1, ['vorname' => 'Moritz']);
}
// ── revealAllAllergies ─────────────────────────────────────────────
public function testRevealAllAllergiesDecryptsEveryMember(): void {
$m1 = $this->createMember(1, 'A', 'Aa');
$m1->setAllergienEncrypted('CT1');
$m2 = $this->createMember(2, 'B', 'Bb');
$m2->setAllergienEncrypted(null);
$m3 = $this->createMember(3, 'C', 'Cc');
$m3->setAllergienEncrypted('CT3');
$this->memberMapper->method('findAll')->willReturn([$m1, $m2, $m3]);
$encryption = $this->createMock(EncryptionService::class);
$encryption->method('decrypt')->willReturnMap([
['CT1', 'Nussallergie'],
['CT3', 'Laktose'],
]);
$service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption
);
$map = $service->revealAllAllergies('alice');
$this->assertSame('Nussallergie', $map[1]);
$this->assertNull($map[2]);
$this->assertSame('Laktose', $map[3]);
}
public function testRevealAllAllergiesLogsCountsNotValues(): void {
$m1 = $this->createMember(1);
$m1->setAllergienEncrypted('CT');
$this->memberMapper->method('findAll')->willReturn([$m1]);
$encryption = $this->createMock(EncryptionService::class);
$encryption->method('decrypt')->willReturn('Nuss');
$this->logger->expects($this->once())
->method('info')
->with(
$this->stringContains('Bulk-Reveal'),
$this->callback(function ($ctx) {
$flat = json_encode($ctx);
return !str_contains($flat, 'Nuss');
})
);
$service = new MemberService(
$this->memberMapper,
$this->addressMapper,
$this->phoneMapper,
$this->emailMapper,
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption
);
$service->revealAllAllergies('alice');
}
public function testRevealAllAllergiesThrowsWithoutEncryptionService(): void {
$this->expectException(\RuntimeException::class);
$this->service->revealAllAllergies('alice');
}
public function testUpdateImportedMemberSucceeds(): void {
// Simulate a member created by import (has all fields set via import path)
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01');
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
new VueLoaderPlugin(),
new webpack.DefinePlugin({
appName: JSON.stringify('mitgliederverwaltung'),
appVersion: JSON.stringify('0.2.10'),
appVersion: JSON.stringify('0.2.11'),
}),
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,