44 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261002-icv | 01 | execute | 1 | 261002-icv | Modul-Freigabe mit Stufe: Benutzen (USE, Standard) und Verwalten (MANAGE) | 2026-10-02 |
|
true |
|
|
|
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Discovered facts the executor can rely on (verified during planning):
RolesGuard,JwtAuthGuard,TenantGuardare globalAPP_GUARDs (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.ModuleRegistryModuleexportsModuleRegistryService,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.getAccessibleModuleIdsis consumed by ModuleGuard,getCatalogFlags,findAccessibleModulesandapps/api/src/dashboard/dashboard.service.ts— its signature must stay.rls-access-inventory.spec.tskeys 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 existingforTenant(...)client of that method, and must not addinclude:/relationselect: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 withModuleAccessGate) AND via the sidebar link/modules/<category>/<slug>(generic[category]/[moduleSlug]/page.tsx→ModuleAccessGate→ModuleShell). Module page components are client components usinguseAuthStore. - i18n namespaces: matrix uses
admin.groups.grants(hasmatrixCheckboxLabel); UserAccessModal usesadmin.users.grants(hasdirectCheckboxLabel); matrix page textsadminModules.grants; gate textsmodules.accessDenied;proxmox.settings.accessDeniedText;kantineDatev.notConfigured.user;handelswareDatev.notConfigured.user.apps/web/src/messages/umlaut-guard.spec.tsrequires 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
Task 1: Tracer — grant level end-to-end: DB column → access levels → ModuleManage guard → kantine settings → /modules/active canManage → kantine settings 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 - 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. **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@: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.
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
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.
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.
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
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.
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.
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
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.
<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= (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> |
| 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 |
<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>