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
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:
+1
-1
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user