2026-04-25 23:08:52 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/**
|
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.
|
2026-04-25 23:08:52 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
*
|
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.
|
|
|
|
|
*
|
2026-04-25 23:08:52 +02:00
|
|
|
* 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')
|
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).
|
2026-04-25 23:08:52 +02:00
|
|
|
*
|
|
|
|
|
* @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');
|
2026-04-25 23:52:27 +02:00
|
|
|
$notFoundFlashScopeKey = $args['notFoundFlashScopeKey'] ?? null;
|
|
|
|
|
$notFoundFlashScopeKey = is_string($notFoundFlashScopeKey) && $notFoundFlashScopeKey !== ''
|
|
|
|
|
? $notFoundFlashScopeKey
|
|
|
|
|
: null;
|
2026-04-25 23:08:52 +02:00
|
|
|
|
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);
|
|
|
|
|
}
|
2026-04-25 23:08:52 +02:00
|
|
|
$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,
|
|
|
|
|
];
|
|
|
|
|
}
|