docs(quick-261002-icv): Freigabestufe Verwalten
Tessera CI/CD / Lint & Type Check (push) Successful in 56s
Tessera CI/CD / Tests (push) Successful in 2m3s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:42:22 +02:00
parent 0e6e55ef65
commit 7188733f70
3 changed files with 458 additions and 1 deletions
+2 -1
View File
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-10-02 - Quick 261002-fm5: neue Kategorie Finanzbuchhaltung mit Kantinenabrechnung + Handelsware (aus HeadFlow portiert), lokal, nicht gepusht Last activity: 2026-10-02 - Quick 261002-icv: Freigabestufe Verwalten (lokal, nicht gepusht); 261002-fm5 Finanzbuchhaltung gepusht (CI 484 gruen)
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -488,6 +488,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) | | 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) | | 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) | | 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
## Deferred Items ## Deferred Items
@@ -0,0 +1,309 @@
---
phase: quick-261002-icv
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-icv
description: "Modul-Freigabe mit Stufe: Benutzen (USE, Standard) und Verwalten (MANAGE)"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB level -> access resolution -> guard -> kantine settings -> /modules/active canManage -> web tab
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/migration-sql.spec.ts
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-access.service.spec.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/module.guard.spec.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/module-grants.service.spec.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
- apps/web/src/lib/api.ts
- apps/web/src/lib/use-module-capability.ts
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
# Task 2 — remaining module conversions (API + their web pages), DKV gate
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- apps/web/src/components/modules/module-access-gate.test.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 3 — admin grant UI with level, docs, changelog, rebuild
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
- CHANGELOG.md
autonomous: true
requirements: [QUICK-261002-icv]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 3
confidence: low
must_haves:
truths:
- "An admin can set every module grant (group or single user) to Benutzen (USE) or Verwalten (MANAGE); grants that existed before the migration are USE"
- "A non-admin with MANAGE on a module can call that module's settings/administration endpoints (2xx) and sees its settings tab/controls; with only USE the same endpoints return 403 and the controls are hidden"
- "If a user has USE via one grant and MANAGE via another for the same module, the effective level is MANAGE"
- "MANAGE on module A grants nothing extra on module B, and a MANAGE grant on a deactivated module grants nothing"
- "Managers still cannot grant/revoke access, activate/deactivate modules, or reach users/groups/LDAP/SMTP/platform-wide tender settings (those stay @Roles(ADMIN, SUPER_ADMIN))"
- "ADMIN and SUPER_ADMIN keep full rights: they resolve to MANAGE on every active module"
- "DKV (dkv-fleet), admin-only for every handler today, becomes manager-level as a whole; USE-level DKV users get no API access (unchanged) and see an explanatory access page"
artifacts:
- path: "apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql"
provides: "ModuleGrantLevel enum + ModuleGrant.level NOT NULL DEFAULT 'USE'"
contains: "ModuleGrantLevel"
- path: "apps/api/src/module-registry/module.guard.ts"
provides: "ModuleManage(slug) decorator + MODULE_MANAGE_KEY enforced by ModuleGuard"
exports: ["ModuleGuard", "UseModule", "ModuleManage", "MODULE_SLUG_KEY", "MODULE_MANAGE_KEY"]
- path: "apps/api/src/module-registry/module-access.service.ts"
provides: "getModuleAccessLevels — single source of truth for access AND level"
- path: "apps/web/src/lib/use-module-capability.ts"
provides: "useCanManageModule(slug) display hook fed by GET /modules/active canManage"
- path: "apps/api/src/module-registry/module-manage-handlers.spec.ts"
provides: "metadata proof: converted handlers use ModuleManage, admin-only handlers keep @Roles"
key_links:
- from: "apps/api/src/module-registry/module.guard.ts"
to: "ModuleAccessService.getModuleAccessLevels"
via: "per-request memo request.moduleAccessLevels"
pattern: "getModuleAccessLevels"
- from: "apps/api/src/groups/dto/create-module-grant.dto.ts"
to: "ModuleGrantsService.grant -> ModuleGrant.level"
via: "POST /module-grants { level }"
pattern: "IsEnum\\(ModuleGrantLevel\\)"
- from: "GET /modules/active (canManage)"
to: "apps/web/src/lib/use-module-capability.ts -> module pages"
via: "useCanManageModule"
pattern: "useCanManageModule"
---
<objective>
Add a second level to every module grant: "Benutzen" (USE, default, today's behavior) and "Verwalten" (MANAGE = use the module AND change that module's own settings/configuration). Backend is the source of truth via a reusable `@ModuleManage('<slug>')` decorator on `ModuleGuard`; the web learns the effective level per module from `GET /modules/active` (`canManage`) and shows settings tabs/controls to admins AND managers. Admins assign the level in the grant matrix (groups) and the user access dialog (single users).
Locked decisions from the request (cited below as L-xx):
- L-01 Only admins grant modules and choose the level; module activation and grant endpoints stay admin-only; managers get no users/groups/global settings.
- L-02 MANAGE grantable to single users AND groups like today; USE+MANAGE via different grants → MANAGE wins.
- L-03 Admins/super-admins implicitly manage every module.
- L-04 Applies to ALL module-scoped admin-only handlers; truly system-wide/cross-module ones stay admin-only and are listed in the SUMMARY with reason. DKV: admin-only usage today → convert whole module to manager-level, document, never widen USE.
- L-05 Reusable decorator/guard, no per-controller ad-hoc checks; web gets the effective level (`canManage`) from the module-list endpoint.
- L-06 Prisma migration: enum level column on ModuleGrant, default USE, hand-written SQL, RLS gates/tests checked.
- L-07 Admin grant UI: level choice per grant (Benutzen / Verwalten), formal German "Sie" + English, level shown in grant lists.
- L-08 Module pages: settings tabs/controls for admins AND managers (replace role checks with per-module capability).
- L-09 Tests: guard/access resolution (user grant, group grant, mixed, admin bypass, no grant), controller metadata, DTO validation, web grant UI + one module settings tab; full api + web suites, tsc, biome on touched files.
- L-10 CHANGELOG (Unveröffentlicht, user-facing German) + docs/ where grants are explained.
- L-11 Local migration via container IP + `docker compose up -d --build api web`; no push.
- L-12 NestJS static routes before `:id` (no new routes planned); never mention "Mandant"/tenant in new user-facing texts.
Output: migration, level-aware access service + guard + decorator, converted controllers (kantine-datev, handelsware-datev, proxmox, dkv-fleet), admin grant UI with level, capability-driven module pages, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- `RolesGuard`, `JwtAuthGuard`, `TenantGuard` are global `APP_GUARD`s (apps/api/src/app.module.ts). Any handler still carrying `@Roles(ADMIN, SUPER_ADMIN)` blocks managers regardless of other guards — converted handlers MUST drop `@Roles`.
- `ModuleRegistryModule` exports `ModuleRegistryService`, `ModuleAccessService`, `ModuleGuard`; DkvModule, KantineDatevModule, HandelswareDatev, Proxmox modules already import it (no DI change needed).
- Module slugs: `kantine-datev`, `handelsware-datev`, `proxmox`, `dkv-fleet` (apps/api/src/dkv/dkv.seed.ts), `tender-radar`, `cert-manager`, `domaincheck`.
- `ModuleAccessService.getAccessibleModuleIds` is consumed by ModuleGuard, `getCatalogFlags`, `findAccessibleModules` and `apps/api/src/dashboard/dashboard.service.ts` — its signature must stay.
- `rls-access-inventory.spec.ts` keys on (file, model) pairs and bound/unbound state: every new Prisma access in module-access.service.ts / module-grants.service.ts MUST go through the existing `forTenant(...)` client of that method, and must not add `include:`/relation `select:` to new models.
- ValidationPipe is global with `whitelist: true, transform: true` (apps/api/src/main.ts).
- Web module pages are reachable via `/modules/<slug>` (own layout with `ModuleAccessGate`) AND via the sidebar link `/modules/<category>/<slug>` (generic `[category]/[moduleSlug]/page.tsx` → `ModuleAccessGate` → `ModuleShell`). Module page components are client components using `useAuthStore`.
- i18n namespaces: matrix uses `admin.groups.grants` (has `matrixCheckboxLabel`); UserAccessModal uses `admin.users.grants` (has `directCheckboxLabel`); matrix page texts `adminModules.grants`; gate texts `modules.accessDenied`; `proxmox.settings.accessDeniedText`; `kantineDatev.notConfigured.user`; `handelswareDatev.notConfigured.user`. `apps/web/src/messages/umlaut-guard.spec.ts` requires real umlauts in de.json.
- Migration convention: hand-written SQL with German header comment (model: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql); latest migration is 20261002130000_handelsware_datev.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push.
@apps/api/src/module-registry/module.guard.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/api/src/groups/module-grants.service.ts
@apps/api/src/groups/dto/create-module-grant.dto.ts
@apps/api/src/kantine-datev/kantine-datev.controller.ts
@apps/web/src/components/modules/module-access-gate.tsx
@apps/web/src/lib/module-access-actions.ts
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — grant level end-to-end: DB column → access levels → ModuleManage guard → kantine settings → /modules/active canManage → kantine settings tab</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql, apps/api/src/groups/migration-sql.spec.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/module-registry/module.guard.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts, apps/api/src/groups/dto/create-module-grant.dto.spec.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/web/src/lib/api.ts, apps/web/src/lib/use-module-capability.ts, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx</files>
<behavior>
- ModuleAccessService.getModuleAccessLevels: USER with only a direct USE grant → Map {m1: USE}; direct USE + group MANAGE on same module → MANAGE (L-02); MANAGE only via group → MANAGE; MANAGE grant on a module whose TenantModuleActivation is inactive → absent; ADMIN and SUPER_ADMIN → every active module = MANAGE without grant queries (L-03); no grants → empty Map; rows without a `level` field (old mocks) count as USE.
- getAccessibleModuleIds still returns exactly the key set (existing spec cases keep passing); findAccessibleModules rows carry `canManage` true/false.
- ModuleGuard: @UseModule route + USE → allowed; @ModuleManage route + USE → ForbiddenException; + MANAGE → allowed; admin → allowed; no grant → ForbiddenException; MANAGE on module "a" while the route is ModuleManage('b') → ForbiddenException; a second canActivate on the same request object reuses request.moduleAccessLevels and does not call the service again; ModuleManage(slug) sets MODULE_SLUG_KEY, MODULE_MANAGE_KEY=true and guards metadata containing ModuleGuard.
- CreateModuleGrantDto: level 'USE', 'MANAGE' or omitted → valid; 'ADMIN' and lowercase 'manage' → validation error on `level`.
- ModuleGrantsService.grant: no level → create with USE; level MANAGE → create with MANAGE; existing USE row + level MANAGE → update to MANAGE and log line; existing MANAGE row + no level → no update, existing returned (repeat click never downgrades); P2002 race + level given → same update rule.
- migration-sql.spec: the new migration contains the CREATE TYPE and ADD COLUMN statements below.
- Kantine controller metadata: saveSettings has MODULE_MANAGE_KEY true, slug 'kantine-datev', no ROLES_KEY; getSettings/preview/export have no MODULE_MANAGE_KEY.
- Kantine web page: USER whose /modules/active entry has canManage true sees the "Einstellungen" tab; USER with canManage false does not; ADMIN sees it without any /modules/active fetch.
</behavior>
<action>
**Schema + migration (L-06).** In `apps/api/prisma/schema.prisma` add `enum ModuleGrantLevel { USE MANAGE }` next to `enum MembershipSource`, and on `model ModuleGrant` add `level ModuleGrantLevel @default(USE)` with a German comment (261002-icv: Freigabestufe; USE = Benutzen, Standard und Bestand; MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen ändern; Freigaben erteilen bleibt Administratoren vorbehalten). Hand-write `apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql`: German header comment in the style of 20261002120000_kantine_datev_config (purpose; existing rows become USE through the DEFAULT, so nobody gains rights; no new table, the existing ModuleGrant row policies filter rows not columns and stay unchanged, so rls-coverage needs nothing; PostgreSQL grants USAGE on new types to PUBLIC, so tessera_app can use the enum; switch-is-off note). Statements, exactly: `CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');` and `ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`. Add a describe block to `apps/api/src/groups/migration-sql.spec.ts` using its `readMigrationSql('_module_grant_level')` helper asserting both statements. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally (L-11): IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`, confirm with `prisma migrate status` and a drift check `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` (same env, run in apps/api via the filter) — exit code 0 means schema and SQL agree.
**Access resolution (L-02, L-03, L-05).** In `module-access.service.ts` add `getModuleAccessLevels(tenantId, userId, role): Promise<Map<string, ModuleGrantLevel>>` as the single resolution. Admin/SUPER_ADMIN branch: same activation query as today, every moduleId → MANAGE. Other roles: the same two grant queries as today (direct `userId`, group via `group: { memberships: { some: { userId } } }`), now selecting `{ moduleId: true, level: true }`; merge so MANAGE wins (any value other than 'MANAGE' counts as USE); intersect with the same active-activation query as today. Use the one `forTenant` client of the method for all accesses (rls-access-inventory). Rewrite `getAccessibleModuleIds` to return the key set of `getModuleAccessLevels` (signature unchanged). `findAccessibleModules` returns each catalog row spread plus `canManage: level === 'MANAGE'` (catalog query stays on `this.prisma` as today). Update the class/method doc comments (Freigabestufe, 261002-icv). Update the doc comment of `GET /modules/active` in module-registry.controller.ts only if you touch it — no code change there is needed.
**Guard + decorator (L-05).** In `module.guard.ts` export `MODULE_MANAGE_KEY = 'moduleManage'`. In `canActivate`, after the existing slug/tenant/user/findBySlug steps: read `requireManage` with `reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [handler, class])`; take `request.moduleAccessLevels` if it is already a Map (class-level @UseModule plus handler-level @ModuleManage run this guard twice per request), else call `getModuleAccessLevels`; no entry for module.id → existing "not accessible" ForbiddenException; requireManage and level not MANAGE → ForbiddenException with message `Module '<slug>' requires manage permission`; store `request.moduleAccessLevels` and keep setting `request.moduleAccessIds` (Set of keys). Export `ModuleManage(slug)` = applyDecorators(SetMetadata(MODULE_SLUG_KEY, slug), SetMetadata(MODULE_MANAGE_KEY, true), UseGuards(ModuleGuard)) with a German JSDoc: replaces `@Roles(ADMIN, SUPER_ADMIN)` for module-scoped configuration; usable on a handler inside a @UseModule controller or on a whole controller; admins pass via the D-03 short-circuit; never combine with @Roles on the same handler (global RolesGuard would still block managers); tenant/user/role only from the JWT (T-15-10). Update `module.guard.spec.ts` mocks from `getAccessibleModuleIds` to `getModuleAccessLevels` and add the behavior cases.
**Grant write side (L-01, L-02).** `CreateModuleGrantDto`: add optional `level?: ModuleGrantLevel` with `@IsOptional()` and `@IsEnum(ModuleGrantLevel)` (import from @prisma/client); doc comment: level is ignored on DELETE. New `apps/api/src/groups/dto/create-module-grant.dto.spec.ts` following `apps/api/src/custom-modules/dto/custom-module.dto.spec.ts` (plainToInstance + validate). `ModuleGrantsService.grant` accepts `level?: ModuleGrantLevel`; keep the existing check order (XOR, tenant cross-check, activation). Then find the existing row for the exact target with `tenantPrisma.moduleGrant.findFirst` (same where as the current P2002 branch): if it exists and a level was given that differs → `tenantPrisma.moduleGrant.update({ where: { id: existing.id }, data: { level } })`, log `Grant-Stufe geändert: tenant=… module=… <target> level=<level>`, return it; if it exists otherwise → return it unchanged (no level given never changes the level). If not, create with `level: level ?? 'USE'` and add `level=` to the existing log line; the P2002 branch applies the same rule. Replace the outdated class comment sentence about the record carrying no level (old D-04) with: since 261002-icv the row carries `level`; only this admin-only service sets it. Controller unchanged (stays admin-only, L-01). Extend `module-grants.service.spec.ts` with the grant cases.
**First converted handler.** In `kantine-datev.controller.ts` replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on `saveSettings` with `@ModuleManage('kantine-datev')`, remove now-unused `Role`/`Roles` imports, and update the header comment (settings: administrators and users with Freigabestufe Verwalten, 261002-icv). Update `kantine-datev.controller.spec.ts` (its ROLES_KEY expectation on saveSettings becomes the MODULE_MANAGE_KEY expectation).
**Web capability (L-05, L-08).** `apps/web/src/lib/api.ts`: add `canManage?: boolean` to `ApiModule`. New `apps/web/src/lib/use-module-capability.ts` exporting `useCanManageModule(moduleSlug: string): boolean | null`: null while `useAuthStore` user is null; true immediately for ADMIN/SUPER_ADMIN (mirrors the backend short-circuit, no fetch); otherwise one fetch of `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/modules/active` with `credentials: 'include'`, `cache: 'no-store'`, result true only if an entry has this slug AND `canManage === true`; non-ok or thrown → false; ignore results after unmount. German doc comment: display only, ModuleGuard is the binding check. In `kantine-datev/page.tsx` replace the `isAdmin` role check with `useCanManageModule('kantine-datev') === true` (settings tab + BillingTab hint), renaming the BillingTab prop `isAdmin` → `canManage`. Extend `kantine-datev.test.tsx`: stub global fetch (vi.stubGlobal, unstub in afterEach) answering `/modules/active` with `[{ slug: 'kantine-datev', canManage: true }]` or `canManage: false`, plus an ADMIN case asserting fetch was not called; existing ADMIN/USER cases must stay green.
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root), commit `feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/groups src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>Migration applied locally (`prisma migrate status` up to date, drift check exit 0); listed api/web tests green incl. rls gates; both type-checks clean; a USER with a MANAGE grant passes `PUT /modules/kantine-datev/settings` guard logic (unit-proven) and sees the Einstellungen tab; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Convert remaining module-scoped admin handlers (proxmox, handelsware-datev, dkv-fleet) + their web pages and DKV access page</name>
<files>apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox-client.service.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/module-access-actions.ts, apps/web/src/components/modules/module-access-gate.tsx, apps/web/src/components/modules/module-access-gate.test.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- module-manage-handlers.spec (metadata, L-04/L-09): DkvController class has MODULE_SLUG_KEY 'dkv-fleet', MODULE_MANAGE_KEY true, guards metadata (GUARDS_METADATA from @nestjs/common/constants) containing ModuleGuard, and none of its prototype methods has ROLES_KEY; ProxmoxController create/update/remove/poll/test/testDraft have MODULE_MANAGE_KEY true + slug 'proxmox' and no ROLES_KEY, `list` has neither; KantineDatevController.saveSettings and HandelswareDatevController.saveSettings are manage-level; STAY ADMIN-ONLY: TendersController getSourceConfig/saveSourceConfig/pollNow have ROLES_KEY [ADMIN, SUPER_ADMIN] and no MODULE_MANAGE_KEY; ModuleGrantsController matrix/userAccess/create/remove and ModuleRegistryController activate/deactivate have ROLES_KEY [ADMIN, SUPER_ADMIN].
- Gate: slug 'dkv-fleet' with level 'manage' → children; 'use' → denied page with the manage-required body text; 'none' or thrown → standard denied text; other slugs keep calling checkModuleAccess exactly once (existing tests unchanged).
- Handelsware page: USER with canManage true sees the settings tab; false does not.
- Proxmox: USER with canManage true sees the manager controls (poll button / settings link / enabled form); USER with canManage false keeps today's read-only view; ADMIN/SUPER_ADMIN unchanged.
</behavior>
<action>
**API conversions (L-04, L-05).** `proxmox.controller.ts`: replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on create, update, remove, poll, test and testDraft with `@ModuleManage('proxmox')`; `GET servers` stays USE (class @UseModule); drop unused imports; update the header comment. `proxmox-client.service.ts`: comment only — the SSRF safeguard is now "administrator or a user the administrator explicitly granted Verwalten for the Proxmox module (`@ModuleManage('proxmox')`, 261002-icv)". `handelsware-datev.controller.ts`: `saveSettings` → `@ModuleManage('handelsware-datev')` (account routes are already USE-level, leave them), update header comment and `handelsware-datev.controller.spec.ts` (ROLES_KEY expectation → MODULE_MANAGE_KEY). `dkv.controller.ts`: today it has NO @UseModule and every one of its 11 handlers carries @Roles(ADMIN, SUPER_ADMIN) — i.e. DKV usage itself is admin-only. Per L-04 put `@ModuleManage('dkv-fleet')` on the class, remove all 11 per-handler @Roles plus the `Role`/`Roles` imports, and rewrite the header comment: whole module is Verwalten-level; USE-level users keep getting 403 exactly as before (not widened); the class guard additionally requires the dkv-fleet activation (the web page already required it). No route order changes anywhere (L-12).
**Leave admin-only (do not edit; list in SUMMARY with reason):** tenders `getSourceConfig`/`saveSourceConfig`/`pollNow` (platform-wide singleton poll config and upstream fetch for the whole installation, not module-per-company configuration); tenders `createRssFeed` scope 'platform' and `removeRssFeed` platform-feed branch (platform-wide feeds shown to every user of the installation); module-registry activate/deactivate and all module-grants routes (L-01); custom-modules shared entries (not a registry module, no @UseModule, sidebar entries for everyone); groups/user/ldap/settings (SMTP)/tenant/welcome-mail controllers (global administration). Confirm with `grep -rn "Roles(" apps/api/src --include=*.ts` that cert-manager, domaincheck and reminders have no admin-only handler; mention that in the SUMMARY.
New `apps/api/src/module-registry/module-manage-handlers.spec.ts` with the metadata assertions from behavior (if importing several controllers in one file proves problematic, split per controller next to it and adjust files list in the SUMMARY).
**Web (L-08).** `module-access-actions.ts`: add `getModuleAccessLevel(moduleSlug): Promise<'none' | 'use' | 'manage'>` (same cookie forwarding and fail-closed handling, reading `canManage` from /modules/active); make `checkModuleAccess` delegate (`!== 'none'`) with unchanged signature. `module-access-gate.tsx`: add `MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet'])` with a German comment pointing at the class-level @ModuleManage on DkvController; for those slugs call getModuleAccessLevel ('manage' → children, 'use' → ModuleAccessDenied with body `t('accessDenied.manageRequiredBody')`, else the standard denied texts); all other slugs keep the current checkModuleAccess path. Extend `module-access-gate.test.tsx`. Handelsware: `useCanManageModule('handelsware-datev') === true` replaces isAdmin in page.tsx; rename ImportTab prop `isAdmin` → `canManage`. Proxmox: `useCanManageModule('proxmox')` in page.tsx and settings/page.tsx (settings page shows its existing loading placeholder while the value is null, the denied text when false); rename the `isAdmin` props of ServerCard and ServerForm to `canManage` and update their tests and code comments (e.g. the idle-text comment about the poll endpoint). Update `proxmox-page-roles.test.tsx` (stub fetch for USER cases: `[]` for read-only, `[{ slug: 'proxmox', canManage: true }]` for the new manager case) and add one handelsware manager case.
**Texts (de + en, formal "Sie", real umlauts, no "Mandant"/tenant in new wording, L-12):** `proxmox.settings.accessDeniedText` → „Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.“ / "This page is available to administrators and to users who may manage this module."; in `kantineDatev.notConfigured.user` and `handelswareDatev.notConfigured.user` replace only the leading „Ein Administrator muss“ with „Ein Administrator oder jemand, der dieses Modul verwalten darf, muss“ (rest verbatim; en: "An administrator or someone who may manage this module must …"); new `modules.accessDenied.manageRequiredBody` → „Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.“ / "This module is only available to users who may manage it. Please contact your administrator.".
Biome-lint touched files, commit `feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/kantine-datev src/handelsware-datev src/proxmox src/dkv src/tenders src/groups && pnpm --filter @tessera/web exec vitest run proxmox handelsware-datev kantine-datev module-access umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/dkv/dkv.controller.ts apps/api/src/proxmox/proxmox.controller.ts apps/api/src/kantine-datev/kantine-datev.controller.ts apps/api/src/handelsware-datev/handelsware-datev.controller.ts)" && grep -qE '^\s*@Roles\(' apps/api/src/tenders/tenders.controller.ts</automated>
</verify>
<done>No decorator-level @Roles left in the four converted controllers, tenders keeps its @Roles on getSourceConfig/saveSourceConfig/pollNow (proven by the metadata spec); metadata spec proves converted vs. admin-only handlers; proxmox/handelsware/kantine pages and DKV gate follow canManage; tests and type-checks green; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Admin grant UI with level (matrix + user dialog), docs, changelog, full suites, local rebuild</name>
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-administration.md, docs/anleitung-anwender.md, CHANGELOG.md</files>
<behavior>
- getMatrix: each grant item is { moduleId, groupId, level }.
- getUserAccess: each module row keeps { module, viaGroups, direct } and adds `directLevel` (level of the direct grant or null) and `manageViaGroups` (display names of the groups granting MANAGE, subset of viaGroups).
- Matrix: a granted cell shows a level select with Benutzen/Verwalten reflecting the response; choosing Verwalten POSTs /module-grants with { moduleId, groupId, level: 'MANAGE' }; a failed POST rolls the select back; ticking an empty cell POSTs without level and shows Benutzen.
- User dialog: direct grant row shows a level select; changing it POSTs { moduleId, userId, level }; a group chip granting MANAGE shows the „Verwalten“ marker.
</behavior>
<action>
**API read side for the UI (L-07).** In `module-grants.service.ts` `getMatrix`: add `level: true` to the group-grant select and return `level` per grant item. `getUserAccess`: select `{ moduleId: true, level: true }` for direct grants; group grants already include the row (use its `level`); add `directLevel` and `manageViaGroups` per row as in behavior, leaving existing keys untouched. Keep every access on the method's `tenantPrisma` (rls-access-inventory). Extend the spec.
**Matrix page (`admin/modules/grants/page.tsx`).** State becomes a Map cellKey → 'USE' | 'MANAGE'. For a granted cell render, next to the checkbox, a compact native select (options from `admin.groups.grants.levelUse` / `levelManage`, aria-label `levelSelectLabel` with module + group) styled like the page inputs and disabled while that cell is saving; on change POST `/module-grants` with `{ moduleId, groupId, level }` using the same optimistic update + rollback + error banner as `toggleGrant`. Ticking an empty cell keeps POSTing without level (server default USE) and stores USE locally; revoke unchanged. Below the table render `adminModules.grants.levelExplanation` and the rewritten `adminNote`. Extend `grants-matrix.test.tsx` (its existing fetch-stub pattern).
**User dialog (`UserAccessModal.tsx`).** Extend the row type with `directLevel` and `manageViaGroups`. Group chips whose name is in `manageViaGroups` show the suffix marker `t('manageMarker')`. When `direct` is true, show a level select next to the checkbox (option labels from `admin.groups.grants`, aria-label `t('directLevelLabel', { module, user })`); change POSTs `{ moduleId, userId, level }` with the existing optimistic/rollback pattern; ticking the checkbox sets `directLevel` 'USE' locally. Extend `user-access-modal.test.tsx`.
**Texts (de/en, formal "Sie", no "Mandant"/tenant, real umlauts):** `admin.groups.grants.levelUse` „Benutzen“ / "Use"; `admin.groups.grants.levelManage` „Verwalten“ / "Manage"; `admin.groups.grants.levelSelectLabel` „Stufe für {module} in Gruppe {group}“ / "Level for {module} in group {group}"; `admin.users.grants.directLevelLabel` „Stufe der direkten Freigabe von {module} für {user}“ / "Level of the direct grant of {module} for {user}"; `admin.users.grants.manageMarker` „Verwalten“ / "Manage"; `adminModules.grants.levelExplanation` „„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.“ (en equivalent); rewrite `adminModules.grants.adminNote` → „Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.“ / "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.".
**Docs (L-10, German, formal, new sentences without "Mandant").** `docs/anleitung-administration.md`: chapter 1 bullet on admin access (mention that the level Verwalten exists and admins implicitly have it); chapter 2 "Details zu Gruppen und Modulzugriff" (level select on the direct grant, Verwalten marker on group chips); chapter 5 new subsection "Freigabestufen: Benutzen und Verwalten" — what Verwalten unlocks per module (Kantinenabrechnung: Einstellungen; Handelsware: Einstellungen; Proxmox: Server anlegen, ändern, löschen, prüfen, sofort abrufen; DKV-Rechnung: das gesamte Modul, Benutzen allein reicht dort nicht), what stays admin-only (Freigaben erteilen und Stufe wählen, Module aktivieren, Benutzer, Gruppen, LDAP, SMTP, Willkommensmail, gemeinsame eigene Module, Ausschreibungs-Radar-Plattformeinstellungen: Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds), the higher level wins, existing grants are Benutzen; update the Freigaben-Matrix paragraph (level select per granted cell) and add a Fehlersuche row (user does not see the Einstellungen tab → grant is only Benutzen). `docs/anleitung-anwender.md`: after the Aktivieren/Freigeben paragraph a short paragraph on Verwalten; DKV-Rechnung section note (needs Verwalten); Proxmox server line and the Kantinenabrechnung/Handelsware "Einmalig einrichten" lines → "Administrator oder wer das Modul verwalten darf". `CHANGELOG.md` → under „## Unveröffentlicht“ / „### Neu“ one bullet in the existing user-facing style: two levels Benutzen (as before) and Verwalten; managers change the module's own settings (examples: Kantinenabrechnung, Handelsware, Proxmox-Server) without being administrators; admins choose the level per group in the Freigaben-Matrix or per user in the user details; existing grants stay Benutzen; the DKV-Rechnung module is available to administrators and to users with Verwalten.
**Finish.** Run full suites `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both type-checks, biome lint on every file touched in this plan. Rebuild the local stack from the repo root with `docker compose up -d --build api web`, then check `docker compose logs api --tail 120` for a clean start (no migration/Prisma error). No browser check (orchestrator does it), no push. Commit `feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog` (attribution line). In the SUMMARY list: converted handlers per controller, the admin-only handlers with reasons (from Task 2), the DKV behavior change (now additionally requires activation; USE-level users see the explanatory page), and test counts.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const ks=["admin.groups.grants.levelUse","admin.groups.grants.levelManage","admin.groups.grants.levelSelectLabel","admin.users.grants.directLevelLabel","admin.users.grants.manageMarker","adminModules.grants.levelExplanation","adminModules.grants.adminNote","modules.accessDenied.manageRequiredBody","proxmox.settings.accessDeniedText"];const g=(o,k)=>k.split(".").reduce((a,p)=>a&&a[p],o);for(const k of ks)for(const m of [de,en]){const v=g(m,k);if(typeof v!=="string"||/mandant|tenant/i.test(v)){console.error("bad key",k);process.exit(1)}}' && grep -q "Verwalten" CHANGELOG.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Admins choose Benutzen/Verwalten per group cell and per direct user grant, lists show the level; full api + web suites, both type-checks and biome green; docs and changelog updated; api and web containers rebuilt and running with a clean api log; commit on main, not pushed; SUMMARY lists admin-only handlers with reasons.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (module routes) | untrusted caller; identity/role/tenant only from the validated JWT |
| admin browser → POST /module-grants | the `level` field is client-supplied and decides future privileges |
| web UI capability display | `canManage` in the page is display only; ModuleGuard is binding |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-icv-01 | Elevation of Privilege | module-grants.controller.ts | high | mitigate | Grant/revoke routes keep @Roles(ADMIN, SUPER_ADMIN) (L-01); metadata spec in module-manage-handlers.spec.ts asserts it, so a manager cannot grant themselves or others |
| T-icv-02 | Elevation of Privilege | ModuleGuard / ModuleManage | high | mitigate | Level resolved server-side from ModuleGrant rows via getModuleAccessLevels using JWT userId/role/tenantId only; never from body/query; guard spec covers USE→403, MANAGE→ok, no grant→403 |
| T-icv-03 | Tampering | CreateModuleGrantDto.level | medium | mitigate | @IsOptional + @IsEnum(ModuleGrantLevel); DTO spec rejects 'ADMIN' and lowercase values; global whitelist ValidationPipe |
| T-icv-04 | Elevation of Privilege | cross-module scope | high | mitigate | MANAGE checked for the route's own slug's moduleId only; guard test: MANAGE on A → 403 on ModuleManage('b') |
| T-icv-05 | Elevation of Privilege | deactivated module | medium | mitigate | Levels intersected with active TenantModuleActivation (same as today); service test for inactive-module MANAGE grant |
| T-icv-06 | Elevation of Privilege | converted handlers still carrying @Roles or missing guard | high | mitigate | Verify gate: no decorator-level @Roles in the 4 converted controllers; metadata spec asserts MODULE_MANAGE_KEY + ModuleGuard on each converted handler/class |
| T-icv-07 | Elevation of Privilege | platform-wide tender settings, activation, users/groups/SMTP | high | mitigate | Left on @Roles(ADMIN, SUPER_ADMIN); metadata spec (tenders getSourceConfig/saveSourceConfig/pollNow, module-grants, activate/deactivate keep ROLES_KEY) plus the tenders @Roles presence gate prove nothing global was widened |
| T-icv-08 | Elevation of Privilege | DKV (dkv-fleet) | medium | mitigate | Whole controller @ModuleManage('dkv-fleet'): USE-level users stay at 403 as before (no silent widening); web gate shows explanatory page |
| T-icv-09 | Information Disclosure / SSRF | proxmox server addresses entered by managers | medium | accept | Proxmox targets are private by design (T-DHH-02, no address filter possible); the admin explicitly delegates via Verwalten; documented in proxmox-client.service.ts comment and admin docs |
| T-icv-10 | Repudiation | grant level changes | low | mitigate | Logger lines for create and level change include level=<level> (D-23 pattern, no audit table) |
| T-icv-11 | Tampering | repeated grant click downgrading MANAGE | low | mitigate | grant() never changes level when no level is sent; service test |
| T-icv-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages in this plan; nothing to verify |
</threat_model>
<verification>
- Task verify commands above all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, migration-sql specs).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after rebuild; api log clean.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: second grant level USE/MANAGE, managers change own module settings | Tasks 1-3 |
| L-01 admin-only grants/activation/global admin | Task 1 (controller untouched), Task 2 (metadata spec), T-icv-01/07 |
| L-02 users + groups, MANAGE wins | Task 1 (service + tests), Task 3 (UI both places) |
| L-03 admins implicit manage | Task 1 (short-circuit → MANAGE, hook short-circuit) |
| L-04 all module-scoped handlers, global ones listed, DKV converted | Task 1 (kantine), Task 2 (proxmox, handelsware, dkv, admin-only list) |
| L-05 reusable decorator/guard, canManage in /modules/active | Task 1 |
| L-06 migration default USE, RLS gates | Task 1 |
| L-07 admin grant UI level choice + display | Task 3 |
| L-08 module pages for admins AND managers | Task 1 (kantine), Task 2 (handelsware, proxmox, DKV gate) |
| L-09 tests + full suites + tsc + biome | Tasks 1-3 |
| L-10 CHANGELOG + docs | Task 3 |
| L-11 local migration + rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
| L-12 route order, no Mandant in texts | Task 2/3 text rules + node key check |
</verification>
<success_criteria>
- ModuleGrant has `level` (USE default); all existing grants are USE.
- `@ModuleManage(slug)` exists and is used by kantine-datev saveSettings, handelsware-datev saveSettings, six proxmox write handlers, and the whole DkvController; no @Roles remains on those handlers.
- GET /modules/active returns `canManage`; module pages show settings/controls for admins and managers only.
- Admin matrix and user dialog set and show the level.
- Full api + web test suites, both tsc runs and biome on touched files are green; local stack rebuilt; three commits on main, not pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/261002-icv-SUMMARY.md` when done. It MUST contain a section "Bewusst nur für Administratoren" listing each handler kept admin-only with its reason, and a section on the DKV behavior change.
</output>
@@ -0,0 +1,147 @@
---
phase: quick-261002-icv
plan: 01
quick_id: 261002-icv
subsystem: module-grants
tags: [berechtigungen, freigabestufe, module-guard, prisma, nestjs, nextjs]
status: complete
completed: 2026-10-02
commits: 3
plan_head_before: b94d267584398be7ccf714954dbd71ce577ef301
plan_head_after: eaf2c4574afb41f0787eb17874b709bd0faf1a01
actuals:
tasks: 3
commits: 3
requires: []
provides:
- "ModuleGrant.level (USE/MANAGE), Bestand = USE"
- "ModuleAccessService.getModuleAccessLevels als einzige Auflösung für Zugriff und Stufe"
- "@ModuleManage(slug) am ModuleGuard"
- "GET /modules/active liefert canManage je Modul"
- "Web-Hook useCanManageModule(slug)"
affects: [kantine-datev, handelsware-datev, proxmox, dkv-fleet, admin-freigaben]
key-files:
created:
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/use-module-capability.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"
decisions:
- "Stufe wird ausschließlich serverseitig aus ModuleGrant-Zeilen aufgelöst; MANAGE gewinnt bei mehreren Wegen."
- "Wiederholter Klick auf eine Matrix-Zelle (POST ohne level) ändert die Stufe nie."
- "DKV-Fleet wird als Ganzes Verwalten-Stufe, Benutzen-Stufe bekommt keinen API-Zugriff (wie bisher)."
---
# Quick 261002-icv: Modul-Freigabe mit Stufe Benutzen und Verwalten
Jede Modul-Freigabe (Gruppe oder einzelner Benutzer) hat jetzt eine Stufe: **Benutzen** (USE, Standard und Bestand) oder **Verwalten** (MANAGE, zusätzlich die eigenen Einstellungen dieses einen Moduls ändern). Die Stufe wird im Backend über den neuen Dekorator `@ModuleManage('<slug>')` am `ModuleGuard` erzwungen. Die Weboberfläche erfährt die wirksame Stufe aus `GET /modules/active` (`canManage`) und zeigt Einstellungen nur Administratoren und Verwaltern. Administratoren vergeben die Stufe in der Freigaben-Matrix (je Gruppe) und im Benutzer-Detaildialog (je Benutzer).
## Was gebaut wurde
**Datenbank (Task 1).** Migration `20261002140000_module_grant_level` (von Hand geschrieben): `CREATE TYPE "ModuleGrantLevel"` und `ADD COLUMN "level" ... NOT NULL DEFAULT 'USE'`. Lokal angewendet über die Container-IP; `prisma migrate status` aktuell, Drift-Prüfung (`migrate diff --exit-code`) Rückgabewert 0. Die vorhandenen vier Freigaben stehen lokal alle auf USE. Keine neue Tabelle, daher keine Änderung an RLS-Regeln; `rls-coverage` und `rls-access-inventory` grün.
**Zugriffsauflösung und Wächter (Task 1).**
- `ModuleAccessService.getModuleAccessLevels` ist die einzige Auflösung: ADMIN/SUPER_ADMIN bekommen auf jedem aktiven Modul MANAGE ohne Grant-Abfragen; andere Rollen Direkt- plus Gruppen-Grants, MANAGE gewinnt, geschnitten mit aktiven Aktivierungen (MANAGE auf deaktiviertem Modul zählt nicht). Alles über denselben `forTenant`-Klienten. `getAccessibleModuleIds` ist nur noch die Schlüsselmenge (Signatur unverändert), `findAccessibleModules` liefert `canManage`.
- `ModuleGuard` liest `MODULE_MANAGE_KEY`, nutzt pro Request `request.moduleAccessLevels` (Klassen-`@UseModule` plus Handler-`@ModuleManage` fragen den Dienst nur einmal) und wirft bei Stufe USE `Module '<slug>' requires manage permission`. MANAGE gilt nur für das Modul der Route.
- `CreateModuleGrantDto.level` (optional, `@IsEnum`). `ModuleGrantsService.grant`: ohne Stufe USE; vorhandene Freigabe wird nur bei ausdrücklich anderer Stufe geändert (Logzeile `Grant-Stufe geändert … level=…`), ein Wiederholungsklick stuft nie herab; P2002-Wettlauf wendet dieselbe Regel an.
**Umgestellte Handler (Tasks 1 und 2).**
| Controller | Auf `@ModuleManage` umgestellt |
|---|---|
| `KantineDatevController` | `saveSettings` (`kantine-datev`) |
| `HandelswareDatevController` | `saveSettings` (`handelsware-datev`) |
| `ProxmoxController` | `create`, `update`, `remove`, `poll`, `test`, `testDraft` (`proxmox`); `list` bleibt Benutzen |
| `DkvController` | ganze Klasse (`dkv-fleet`), alle 11 früheren `@Roles` entfernt |
In den vier Controllern steht kein dekoratorseitiges `@Roles` mehr.
**Web (Tasks 1 bis 3).** `useCanManageModule(slug)` (Admins sofort `true` ohne Abfrage, sonst eine Abfrage von `/modules/active`, Fehler = `false`). Kantinenabrechnung, Handelsware, Proxmox-Seite, Proxmox-Einstellungen, `ServerCard`/`ServerForm` (Props `isAdmin` zu `canManage`) und das Proxmox-Dashboard-Widget (Einstellungs-Link) folgen `canManage`. Neue Server-Funktion `getModuleAccessLevel`; `checkModuleAccess` delegiert. `ModuleAccessGate` kennt `MANAGE_ONLY_MODULE_SLUGS = {'dkv-fleet'}`. Matrix und Benutzerdialog haben ein Stufen-Auswahlfeld (optimistisch, Rücksprung bei Fehler); Gruppen, die Verwalten gewähren, sind im Dialog mit „Verwalten“ markiert. Die API liefert dafür `level` je Matrix-Eintrag sowie `directLevel` und `manageViaGroups` je Modulzeile.
**Texte, Doku, Changelog.** Neue Schlüssel in `de.json`/`en.json` (formales „Sie“, echte Umlaute, kein „Mandant“/Tenant in den neuen Formulierungen; per Skript geprüft). `docs/anleitung-administration.md` (Kapitel 1, 2, 5 mit neuem Abschnitt „Freigabestufen: Benutzen und Verwalten“, Fehlersuche), `docs/anleitung-anwender.md`, `CHANGELOG.md` (Unveröffentlicht, Neu).
## Bewusst nur für Administratoren
Diese Handler blieben unverändert auf `@Roles(ADMIN, SUPER_ADMIN)`; die Metadaten-Spec `module-manage-handlers.spec.ts` beweist das:
| Handler | Grund |
|---|---|
| `TendersController.getSourceConfig` | plattformweiter Singleton der Abrufeinstellungen für die ganze Installation, keine Konfiguration eines einzelnen Moduls je Firma |
| `TendersController.saveSourceConfig` | wie oben; ändert Abrufintervall und Quelle für alle |
| `TendersController.pollNow` | stößt den plattformweiten Abruf beim Datenanbieter an (Lastschalter, T-lvg-01) |
| `TendersController.createRssFeed` (Bereich „platform“), `removeRssFeed` (plattformweiter Zweig) | prüfen die Rolle inline; plattformweite RSS-Feeds sehen alle Benutzer der Installation. Nicht angefasst |
| `ModuleRegistryController.activate` / `deactivate` | Modul-Aktivierung ist Sache der Administratoren (L-01) |
| `ModuleGrantsController.matrix` / `userAccess` / `create` / `remove` | Freigaben vergeben und Stufe wählen bleibt Administratoren vorbehalten (L-01); sonst könnte sich ein Verwalter selbst Rechte geben (T-icv-01) |
| Benutzer-, Gruppen-, LDAP-, SMTP-, Willkommensmail-, Mandanten-Controller | globale Administration, nicht modulgebunden |
| Gemeinsame eigene Module (`custom-modules`) | kein Registry-Modul und kein `@UseModule`; Seitenleisten-Einträge für alle, Rollenprüfung im Dienst |
Geprüft mit `grep -rn "Roles(" apps/api/src`: `cert-manager`, `domaincheck` und `reminders` haben keinen Administrator-Handler, also nichts umzustellen.
## DKV-Verhalten (Änderung)
Vorher trug jeder der 11 DKV-Handler `@Roles(ADMIN, SUPER_ADMIN)`, das Modul war faktisch nur für Administratoren benutzbar, und der Controller hatte kein `@UseModule`. Jetzt trägt die ganze Klasse `@ModuleManage('dkv-fleet')`:
- Zugriff haben Administratoren und Benutzer mit Stufe Verwalten.
- Benutzer mit nur Benutzen bekommen weiterhin 403 von der API (nichts wurde aufgeweitet).
- Neu ist, dass der Wächter zusätzlich die Aktivierung von `dkv-fleet` für den Mandanten verlangt (die Webseite verlangte sie schon). Ein Administrator ohne Aktivierung wird jetzt von der API ebenfalls abgewiesen.
- `ModuleAccessGate` zeigt Benutzern mit nur Benutzen eine erklärende Zugriffsseite („Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen …“), sonst den Standardtext.
## Tests und Prüfungen (ehrlich)
- **API:** `pnpm --filter @tessera/api test`: 111 Dateien, 1911 Tests, alle grün (vorher in den Teilläufen u. a. `rls-coverage`, `rls-access-inventory`, `migration-sql`).
- **Web:** `pnpm --filter @tessera/web test`: 115 Dateien, 1234 Tests, alle grün (Vollauf vor einer reinen Umbenennung unbenutzter Testparameter; danach die betroffenen `admin`-Tests erneut grün, 7 Dateien, 96 Tests).
- **tsc:** `tsc --noEmit` in api und web ohne Fehler.
- **Biome:** `biome lint` auf allen berührten TS/TSX-Dateien ohne neue Meldungen. Zwei bereits vorhandene Warnungen bleiben bestehen und stammen nicht aus diesem Plan (`noAssignInExpressions` in `migration-sql.spec.ts`, `noArrayIndexKey` in `grants/page.tsx`).
- **Lokaler Stand:** `docker compose up -d --build api web` erfolgreich; api, web und db laufen; Log meldet `Nest application successfully started`, kein Migrations- oder Prisma-Fehler. Browserprüfung macht der Orchestrator.
## Abweichungen vom Plan
**1. [Rule 2 - Konsistenz] Proxmox-Dashboard-Widget auf `canManage` umgestellt**
- **Gefunden bei:** Task 2
- **Problem:** `proxmox-widget.tsx` blendete den Link „Zu den Einstellungen“ nur für Administratoren ein, obwohl Verwalter die Einstellungsseite jetzt nutzen dürfen. Die Datei stand nicht in der Dateiliste des Plans.
- **Fix:** `useCanManageModule('proxmox')` statt Rollenprüfung; bestehender Widget-Test blieb grün.
- **Commit:** c2ebc8d
**2. [Rule 1 - Typfehler] `applyToExisting` in `ModuleGrantsService.grant`** brauchte den Typ `ModuleGrant`, damit `tsc` die Spec akzeptiert. Behoben vor dem Commit von Task 1 (a222711).
Sonst: Plan wie geschrieben ausgeführt. Die Metadaten-Spec liegt wie geplant als eine Datei vor. Beim Handelsware-Test wurde eine Textprüfung (`/Ein Administrator muss zuerst/`) an den neuen Wortlaut angepasst, ebenso bei der Kantinenabrechnung.
## Bekannte Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells des Plans. Hinweis zu T-icv-09: Wer für Proxmox „Verwalten“ erhält, darf Serveradressen eintragen (private Ziele per Design); das steht im Kommentar von `proxmox-client.service.ts` und im Administrationshandbuch.
## Commits (nicht gepusht)
- `a222711` feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- `c2ebc8d` feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- `eaf2c45` feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
## Self-Check: PASSED
- Migration, `module.guard.ts`, `use-module-capability.ts`, `module-manage-handlers.spec.ts`, `create-module-grant.dto.spec.ts` vorhanden.
- Alle drei Commit-Hashes existieren auf `main` (`git rev-list --count` über das Ledger: 3).
- Keine unbeabsichtigten Löschungen in den Commits.
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
- Testgruppe „Buchhaltung Test“ mit Testbenutzer (USER) per API angelegt; in der Freigaben-Matrix Kantinenabrechnung = Verwalten, Handelsware = Benutzen gesetzt, bleibt nach Neuladen erhalten.
- Als Testbenutzer: Kantine zeigt Reiter Einstellungen, Speichern klappt; Handelsware ohne Einstellungs-Reiter; API: Handelsware-Einstellungen PUT → 403, DKV ohne Freigabe → 403.
- Korrektur: Matrix zeigte Kategorie-Kennungen („accounting“) → jetzt Anzeigenamen (Finanzbuchhaltung, Fuhrpark, …).
- Testbenutzer und Testgruppe wieder gelöscht.