352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
|
|
<?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);
|
||
|
|
}
|