1
0
Files
breadcrumb-the-shire/core/Support/helpers/action_context.php
fs 9ec10f5c02 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

312 lines
13 KiB
PHP

<?php
/**
* 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.
*
* 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')
* - 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');
$notFoundFlashScopeKey = $args['notFoundFlashScopeKey'] ?? null;
$notFoundFlashScopeKey = is_string($notFoundFlashScopeKey) && $notFoundFlashScopeKey !== ''
? $notFoundFlashScopeKey
: null;
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,
];
}