refactor(actions): introduce action-context helpers (step 1)
Six orthogonal building blocks plus three cluster aggregators in
core/Support/helpers/action_context.php, preparing consolidation of the
~40-60 line vorspiel duplicated across edit/create/view-fragment actions.
Step 1 of a planned 3-step rollout: no production call sites yet —
pages/ and modules/ are untouched. Architecture tests freeze the
building-block signatures and verify drawer-fragment AuthZ parity.
GR-SEC-009 is structurally enforced via the actionDeriveTenantScope
return shape (PHPStan array{scope: 'all'|'list', ids: list<int>});
'all' is unreachable without an explicit can_manage_all_tenants flag.
Aggregator docblocks carry a mandatory CSRF-pairing warning per
GR-SEC-001; actionBuildViewAuth flags the e()-escape obligation per
GR-SEC-010.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// Load all global helper groups used by templates and page actions.
|
||||
require __DIR__ . '/helpers/admin_settings.php';
|
||||
require __DIR__ . '/helpers/app.php';
|
||||
require __DIR__ . '/helpers/action_context.php';
|
||||
require __DIR__ . '/helpers/array.php';
|
||||
require __DIR__ . '/helpers/branding.php';
|
||||
require __DIR__ . '/helpers/export.php';
|
||||
|
||||
351
core/Support/helpers/action_context.php
Normal file
351
core/Support/helpers/action_context.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Step 1: helpers introduced without production callers; produced by run
|
||||
* 2026-04-25-action-context-helper. The 6 orthogonal building blocks plus
|
||||
* the 3 cluster aggregators centralize the ~40-80 line pre-render preamble
|
||||
* that every Edit/Create/View/Drawer-Fragment action repeats today.
|
||||
*
|
||||
* No page action or module page is allowed to call these yet — Step 2 is the
|
||||
* Departments-Edit pilot and Step 3 is the cluster-wide rollout.
|
||||
*/
|
||||
|
||||
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)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer-fragment variant of actionResolveModelOrFail + Authorize.
|
||||
*
|
||||
* Drawer fragments must NOT redirect (CLAUDE.md). They return HTTP status
|
||||
* codes 400/403/404 instead. This helper produces a status DTO that the
|
||||
* caller translates into http_response_code() + early return.
|
||||
*
|
||||
* Usage:
|
||||
* $r = actionFragmentResolveOrStatus($finder, $rawId, ABILITY_VIEW, [...]);
|
||||
* if ($r['status'] !== 'ok') { http_response_code($r['http_code']); return; }
|
||||
*
|
||||
* @param callable(string): mixed $finder Returns the resolved model or null.
|
||||
* @param string $rawId Raw id from the URL.
|
||||
* @param string $abilityKey Ability constant for authorize().
|
||||
* @param array<string, mixed> $context Authorize context payload.
|
||||
*
|
||||
* @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array<string, mixed>, http_code?: int}
|
||||
*/
|
||||
function actionFragmentResolveOrStatus(
|
||||
callable $finder,
|
||||
string $rawId,
|
||||
string $abilityKey,
|
||||
array $context
|
||||
): array {
|
||||
Guard::requireLogin();
|
||||
|
||||
$trimmedId = trim($rawId);
|
||||
if ($trimmedId === '') {
|
||||
return ['status' => 'invalid_id', 'http_code' => 400];
|
||||
}
|
||||
|
||||
$decision = app(AuthorizationService::class)->authorize($abilityKey, $context);
|
||||
if (!$decision->isAllowed()) {
|
||||
return ['status' => 'forbidden', 'http_code' => 403];
|
||||
}
|
||||
|
||||
$model = $finder($trimmedId);
|
||||
if ($model === null) {
|
||||
return ['status' => 'not_found', 'http_code' => 404];
|
||||
}
|
||||
|
||||
$capabilities = $decision->attribute('capabilities', []);
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'model' => $model,
|
||||
'capabilities' => is_array($capabilities) ? $capabilities : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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')
|
||||
*
|
||||
* @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');
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). Drawer fragments are typically GET, but the warning is intentionally defensive and consistent across aggregators.
|
||||
*
|
||||
* Cluster-10 Drawer-Fragment aggregator: thin wrapper around
|
||||
* actionFragmentResolveOrStatus. Returns the status DTO unchanged so the
|
||||
* caller can translate it to http_response_code(...) + early return.
|
||||
*
|
||||
* Required keys in $args:
|
||||
* - finder: callable(string): mixed
|
||||
* - rawId: string
|
||||
* - abilityKey: string
|
||||
* - context: array<string, mixed>
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
* @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array<string, mixed>, http_code?: int}
|
||||
*/
|
||||
function actionFragmentContext(array $args): array
|
||||
{
|
||||
$finder = $args['finder'];
|
||||
$rawId = (string) $args['rawId'];
|
||||
$abilityKey = (string) $args['abilityKey'];
|
||||
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
|
||||
|
||||
return actionFragmentResolveOrStatus($finder, $rawId, $abilityKey, $context);
|
||||
}
|
||||
Reference in New Issue
Block a user