$context Authorization context payload. * @param string $forbiddenStrategy 'redirect' (default) or 'deny'. * * @return array 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 $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} */ 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 $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 $capabilities Full capabilities map. * @param list $whitelistedFlags Flag names to expose to the view. * * @return array{page: array} */ 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 * - viewAuthFlags: list * 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 $args * @return array{model: mixed, capabilities: array, tenantScope: array{scope: 'all'|'list', ids: list}, viewAuth: array{page: array}} */ 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 * - viewAuthFlags: list * Optional keys: * - forbiddenStrategy: 'redirect'|'deny' * - tenantScopeFlagKey: string * - tenantScopeListKey: string * - canViewPageFlagKey: string * * @param array $args * @return array{capabilities: array, tenantScope: array{scope: 'all'|'list', ids: list}, viewAuth: array{page: array}} */ 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, ]; }