$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)), ]; } /** * 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 $context Authorize context payload. * * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, 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 * - viewAuthFlags: list * Optional keys: * - forbiddenStrategy: 'redirect'|'deny' (default 'redirect') * - tenantScopeFlagKey: string * - tenantScopeListKey: string * - canViewPageFlagKey: string (default 'can_view_page') * * @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'); $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, ]; } /** * 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 * * @param array $args * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, 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); }