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

352 lines
14 KiB
PHP
Raw Normal View History

<?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);
}