refactor(departments-edit): migrate to actionEditContext (step 2)

Pilot migration of pages/admin/departments/edit($id).php onto the
actionEditContext aggregator introduced in step 1. The CONTEXT-stage
vorspiel (lookup → authorize → tenant-scope → can_view_page → viewAuth)
collapses into a single declarative call; the POST branch (CSRF →
SUBMIT-authorize → can_update gate → service call → PRG) stays
callsite-specific as planned.

Three deliberate touches beyond a 1:1 lift:

* Additive aggregator extension: actionEditContext gains an optional
  notFoundFlashScopeKey arg so the dedup scope-key 'department_not_found'
  is preserved without widening the frozen actionResolveModelOrFail
  building-block signature. Pattern is documented as the
  forward-compatibility mechanism for future cluster migrations.
* t() consistency: the not-found message now flows through t() via the
  aggregator. To avoid a partial-translation mix, Flash::success calls
  for 'Department updated' (×2) are also wrapped — German users now see
  fully translated messages instead of a German/English mix.
* Defensive scope consumption: the action now consults
  $tenantScope['scope'] before falling through to the strict-mode
  fallback. The Departments policy never emits can_manage_all_tenants
  today (so behavior is identical), but the action is now resilient to
  future policies that might.

New ActionContextCsrfPairingContractTest enforces actionRequireCsrf()
before any POST body access for actions that use the aggregators —
preventing CSRF-pairing regressions during the cluster-wide rollout
(step 3). The step-1 testNoProductionCallSitesYet guard is removed,
since departments-edit is now the first legitimate caller; the new
pairing test takes over its protective role with a more substantive
guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 23:52:27 +02:00
parent 3207d71244
commit 5378209fed
5 changed files with 408 additions and 78 deletions

View File

@@ -238,6 +238,15 @@ function actionFragmentResolveOrStatus(
* Two-Level-Authorize (CONTEXT + SUBMIT, used by Roles/Permissions) is NOT
* built in — actions that need it call the building blocks directly.
*
* Forward-compatibility pattern: this aggregator's signature uses an assoc
* array $args to permit additive Step 2+ extension (e.g. notFoundFlashScopeKey
* override added with the Departments-Edit pilot in Run
* 2026-04-25-action-context-rollout-step2-departments). Building blocks
* (actionResolveModelOrFail et al.) remain frozen by
* ActionContextHelperContractTest::testBuildingBlocksExistWithStableSignatures;
* caller-specific behavior must be expressed via additive aggregator args, not
* by widening building-block signatures.
*
* Required keys in $args:
* - finder: callable(string): mixed
* - rawId: string
@@ -251,6 +260,14 @@ function actionFragmentResolveOrStatus(
* - tenantScopeFlagKey: string
* - tenantScopeListKey: string
* - canViewPageFlagKey: string (default 'can_view_page')
* - notFoundFlashScopeKey: string|null — when provided, the aggregator
* inlines Flash::error(t($notFoundFlashKey), $notFoundRedirectPath,
* $notFoundFlashScopeKey) + Router::redirect instead of delegating to
* actionResolveModelOrFail (which hardcodes scope-key 'not_found'). Use
* this to preserve a domain-specific dedup-scope-key (e.g.
* 'department_not_found') without widening the frozen building-block
* signature. The scope-key is a flash-dedup identifier only — it must
* not contain PII (GR-SEC-002).
*
* @param array<string, mixed> $args
* @return array{model: mixed, capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
@@ -268,8 +285,24 @@ function actionEditContext(array $args): array
$tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants');
$tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids');
$canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page');
$notFoundFlashScopeKey = $args['notFoundFlashScopeKey'] ?? null;
$notFoundFlashScopeKey = is_string($notFoundFlashScopeKey) && $notFoundFlashScopeKey !== ''
? $notFoundFlashScopeKey
: null;
$model = actionResolveModelOrFail($finder, $rawId, $notFoundFlashKey, $notFoundRedirectPath);
if ($notFoundFlashScopeKey !== null) {
$model = is_callable($finder) ? $finder($rawId) : null;
if ($model === null) {
Flash::error(t($notFoundFlashKey), $notFoundRedirectPath, $notFoundFlashScopeKey);
Router::redirect($notFoundRedirectPath);
// Router::redirect() calls die() in production. Guard for tests
// where executeRedirect=false: caller MUST not rely on this being
// reached — return value mirrors the building-block fallthrough.
$model = null;
}
} else {
$model = actionResolveModelOrFail($finder, $rawId, $notFoundFlashKey, $notFoundRedirectPath);
}
$capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy);
$tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey);
actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy);