Files
breadcrumb-the-shire/core/Support/helpers/action_context.php

312 lines
13 KiB
PHP
Raw Normal View History

<?php
/**
refactor(action-context): remove unused fragment aggregator + building block actionFragmentContext was built spec-driven in step 1 to handle the anticipated drawer-fragment pattern: authorize + finder → status DTO. The action-context rollout (steps 2-11) found that none of the three real drawer fragments (users/view-fragment, addressbook/view-fragment, helpdesk/ticket-fragment) match that pattern — all three delegate to domain services that own their own status models. Step 11 declared them structural exceptions; the helper code remained unused. This commit removes the dead spec: * core/Support/helpers/action_context.php drops actionFragmentContext (~9 LOC) and its building block actionFragmentResolveOrStatus (~30 LOC) plus their docblocks. The MUST-call-actionRequireCsrf warning, present in three aggregator docblocks before, now appears twice (one per remaining aggregator). * tests/Support/Helpers/ActionContextHelperTest.php drops the six unit tests that exercised these functions (~83 LOC). * tests/Architecture/ActionContextHelperContractTest.php drops the fragment building block from the buildingBlocks() data provider (5 entries instead of 6) and removes testFragmentResolveReturnDocblockIsFrozen. The CSRF-warning expectation is updated from 3 to 2 with a code comment explaining the rollback. Verified: * No production caller exists in pages/ or modules/. * All 9 aggregator callers (5 actionEditContext + 4 actionCreateContext) remain unchanged. * ActionContextCsrfPairingContractTest and DetailDrawerFragmentContractTest are deliberately left untouched: their allowlists/recognizer regexes still mention actionFragmentContext, but the patterns now match an empty set — harmless dead text. Documented as open items in the run report; future cleanup is optional and orthogonal to this removal. * QGs all green (PHPUnit 2088 tests, PHPStan level 5, CS-fixer 0 diffs). Net: 3 files, +31/-198 LOC, behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:34:53 +02:00
* Action-context helpers introduced by run 2026-04-25-action-context-helper
* (Step 1) and pruned by run 2026-04-25-action-fragment-context-removal after
* Cluster 10 (Drawer-Fragments) was declared a structural exception in run
* 2026-04-25-action-context-rollout-step11-fragments-exception. The remaining
* 5 orthogonal building blocks plus the 2 cluster aggregators
* (actionEditContext, actionCreateContext) centralize the ~40-80 line
* pre-render preamble that Edit/Create actions repeat. Drawer-fragment actions
* delegate to domain-services with their own status models and are NOT served
* by these helpers see the Step 11 decision record for rationale.
*/
use MintyPHP\Router;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
/**
* Resolve a model by raw id (uuid, integer or code) using the caller's finder.
*
* The finder MUST apply tenant scope itself this helper deliberately stays
* scope-agnostic so it can serve UUID, integer-id and code-keyed actions.
*
* On miss the helper flashes the i18n key and redirects to $redirectPath; the
* caller's page execution ends inside Router::redirect().
*
* @param callable(string): mixed $finder Returns the resolved model or null.
* @param string $rawId Raw id from the URL (already trimmed by router).
* @param string $notFoundFlashKey Translation key for the flash message.
* @param string $redirectPath Target path on miss (e.g. 'admin/users').
*
* @return mixed The resolved model. Never returns when not found (redirect ends execution).
*/
function actionResolveModelOrFail(
callable $finder,
string $rawId,
string $notFoundFlashKey,
string $redirectPath
): mixed {
$model = $finder($rawId);
if ($model !== null) {
return $model;
}
Flash::error(t($notFoundFlashKey), $redirectPath, 'not_found');
Router::redirect($redirectPath);
// Router::redirect() calls die() in production. Guard for tests where
// executeRedirect=false: caller MUST not rely on this being reached.
return null;
}
/**
* Run AuthorizationService::authorize and extract the capabilities map.
*
* Both forbidden strategies set a final HTTP response and end page execution:
* - 'redirect' Router::redirect('error/forbidden') (302)
* - 'deny' Guard::deny() (403)
*
* Neither strategy can be bypassed from the caller both terminate via
* Router::redirect() / die() in production.
*
* @param string $abilityKey Policy ability constant.
* @param array<string, mixed> $context Authorization context payload.
* @param string $forbiddenStrategy 'redirect' (default) or 'deny'.
*
* @return array<string, mixed> The decision's capabilities attribute, or [].
*/
function actionAuthorizeAndExtractCapabilities(
string $abilityKey,
array $context,
string $forbiddenStrategy = 'redirect'
): array {
$decision = app(AuthorizationService::class)->authorize($abilityKey, $context);
if (!$decision->isAllowed()) {
if ($forbiddenStrategy === 'deny') {
Guard::deny();
return [];
}
Router::redirect('error/forbidden');
return [];
}
$capabilities = $decision->attribute('capabilities', []);
return is_array($capabilities) ? $capabilities : [];
}
/**
* Derive the tenant scope tuple from a capabilities map.
*
* Strict semantics (GR-SEC-009): scope='all' is ONLY returned when the
* $allTenantsFlagKey is present and === true. A null/missing/non-true value
* for the flag NEVER widens the scope it falls through to 'list', which
* may legitimately be empty.
*
* The Departments-Edit semantics are the calibration target. Users-Edit has
* a different "null = preserve out-of-scope" semantic that is handled by
* mergeTenantIdsPreservingOutOfScope and is NOT in scope for this helper.
*
* @param array<string, mixed> $capabilities Capabilities map.
* @param string $allTenantsFlagKey Flag key that grants scope='all'.
* @param string $allowedListKey List key for explicit tenant ids.
*
* @return array{scope: 'all'|'list', ids: list<int>}
*/
function actionDeriveTenantScope(
array $capabilities,
string $allTenantsFlagKey = 'can_manage_all_tenants',
string $allowedListKey = 'allowed_tenant_ids'
): array {
if (($capabilities[$allTenantsFlagKey] ?? null) === true) {
return ['scope' => 'all', 'ids' => []];
}
$raw = $capabilities[$allowedListKey] ?? null;
if (!is_array($raw)) {
return ['scope' => 'list', 'ids' => []];
}
$ids = [];
foreach ($raw as $value) {
$intValue = is_numeric($value) ? (int) $value : 0;
if ($intValue > 0) {
$ids[] = $intValue;
}
}
return ['scope' => 'list', 'ids' => array_values(array_unique($ids))];
}
/**
* Enforce a can_view_page-style flag from the capabilities map.
*
* Strict comparison (=== true). No truthy coercion: integers, strings,
* arrays, null all fail. Capabilities are system-internal PHP booleans
* produced by AuthorizationService no vendor enum coercion. If a capability
* source produces non-boolean truthy values, normalize at the source, not
* here.
*
* On failure either Router::redirect('error/forbidden') or Guard::deny() is
* invoked depending on $forbiddenStrategy. Both end page execution.
*
* @param array<string, mixed> $capabilities Capabilities map.
* @param string $flagKey Flag to check (default 'can_view_page').
* @param string $forbiddenStrategy 'redirect' (default) or 'deny'.
*/
function actionEnforceCanViewPage(
array $capabilities,
string $flagKey = 'can_view_page',
string $forbiddenStrategy = 'redirect'
): void {
if (($capabilities[$flagKey] ?? false) === true) {
return;
}
if ($forbiddenStrategy === 'deny') {
Guard::deny();
return;
}
Router::redirect('error/forbidden');
}
/**
* Build the view-auth payload from a whitelisted slice of capabilities.
*
* Return value contains MIXED types from capabilities (bool, int, string,
* array). Caller MUST e()-escape all non-boolean values before view output.
* See GR-SEC-010. Test ActionContextHelperTest verifies the array shape but
* does not enforce escaping that is the view's responsibility.
*
* @param array<string, mixed> $capabilities Full capabilities map.
* @param list<string> $whitelistedFlags Flag names to expose to the view.
*
* @return array{page: array<string, mixed>}
*/
function actionBuildViewAuth(array $capabilities, array $whitelistedFlags): array
{
return [
'page' => array_intersect_key($capabilities, array_flip($whitelistedFlags)),
];
}
/**
* CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only edit-render flows, CSRF is not required.
*
* Cluster-1/2/3 Edit aggregator: resolve model authorize derive tenant
* scope enforce can_view_page build viewAuth.
*
* Two-Level-Authorize (CONTEXT + SUBMIT, used by Roles/Permissions) is NOT
* built in actions that need it call the building blocks directly.
*
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>
2026-04-25 23:52:27 +02:00
* 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
* - notFoundFlashKey: string
* - notFoundRedirectPath: string
* - abilityKey: string
* - context: array<string, mixed>
* - viewAuthFlags: list<string>
* Optional keys:
* - forbiddenStrategy: 'redirect'|'deny' (default 'redirect')
* - tenantScopeFlagKey: string
* - tenantScopeListKey: string
* - canViewPageFlagKey: string (default 'can_view_page')
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>
2026-04-25 23:52:27 +02:00
* - 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>}}
*/
function actionEditContext(array $args): array
{
$finder = $args['finder'];
$rawId = (string) $args['rawId'];
$notFoundFlashKey = (string) $args['notFoundFlashKey'];
$notFoundRedirectPath = (string) $args['notFoundRedirectPath'];
$abilityKey = (string) $args['abilityKey'];
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : [];
$forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect');
$tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants');
$tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids');
$canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page');
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>
2026-04-25 23:52:27 +02:00
$notFoundFlashScopeKey = $args['notFoundFlashScopeKey'] ?? null;
$notFoundFlashScopeKey = is_string($notFoundFlashScopeKey) && $notFoundFlashScopeKey !== ''
? $notFoundFlashScopeKey
: null;
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>
2026-04-25 23:52:27 +02:00
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);
$viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags);
return [
'model' => $model,
'capabilities' => $capabilities,
'tenantScope' => $tenantScope,
'viewAuth' => $viewAuth,
];
}
/**
* CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only create-render flows, CSRF is not required.
*
* Cluster-7 Create aggregator: authorize derive tenant scope enforce
* can_view_page build viewAuth. No model resolution.
*
* Required keys in $args:
* - abilityKey: string
* - context: array<string, mixed>
* - viewAuthFlags: list<string>
* Optional keys:
* - forbiddenStrategy: 'redirect'|'deny'
* - tenantScopeFlagKey: string
* - tenantScopeListKey: string
* - canViewPageFlagKey: string
*
* @param array<string, mixed> $args
* @return array{capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
*/
function actionCreateContext(array $args): array
{
$abilityKey = (string) $args['abilityKey'];
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : [];
$forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect');
$tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants');
$tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids');
$canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page');
$capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy);
$tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey);
actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy);
$viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags);
return [
'capabilities' => $capabilities,
'tenantScope' => $tenantScope,
'viewAuth' => $viewAuth,
];
}