2026-02-04 23:31:53 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Support;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Http\Request;
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\Router;
|
2026-03-04 15:56:58 +01:00
|
|
|
use MintyPHP\Service\Access\AuthorizationDecision;
|
|
|
|
|
use MintyPHP\Service\Access\AuthorizationService;
|
|
|
|
|
use MintyPHP\Service\Auth\AuthService;
|
|
|
|
|
use MintyPHP\Service\Tenant\TenantService;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Session-based web auth guard. Enforces login and RBAC for page actions.
|
|
|
|
|
// For API token authentication, see ApiAuth instead.
|
2026-02-04 23:31:53 +01:00
|
|
|
class Guard
|
|
|
|
|
{
|
2026-03-06 00:44:52 +01:00
|
|
|
// Re-check tenant assignment at most once per minute to pick up permission changes without re-login.
|
2026-02-04 23:31:53 +01:00
|
|
|
private const TENANT_REFRESH_INTERVAL = 60;
|
2026-03-04 15:56:58 +01:00
|
|
|
/** @var (callable(): AuthService)|null */
|
|
|
|
|
private static $authServiceResolver = null;
|
|
|
|
|
/** @var (callable(): TenantService)|null */
|
|
|
|
|
private static $tenantServiceResolver = null;
|
|
|
|
|
/** @var (callable(): AuthorizationService)|null */
|
|
|
|
|
private static $authorizationServiceResolver = null;
|
|
|
|
|
|
|
|
|
|
public static function configure(
|
|
|
|
|
callable $authServiceResolver,
|
|
|
|
|
callable $tenantServiceResolver,
|
|
|
|
|
callable $authorizationServiceResolver
|
|
|
|
|
): void {
|
|
|
|
|
self::$authServiceResolver = $authServiceResolver;
|
|
|
|
|
self::$tenantServiceResolver = $tenantServiceResolver;
|
|
|
|
|
self::$authorizationServiceResolver = $authorizationServiceResolver;
|
|
|
|
|
}
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
public static function requireLogin(string $redirect = 'login'): void
|
|
|
|
|
{
|
|
|
|
|
if (!isset($_SESSION['user'])) {
|
|
|
|
|
Flash::error('Login required', 'login', 'login_required');
|
|
|
|
|
Router::redirect($redirect);
|
|
|
|
|
}
|
|
|
|
|
self::refreshTenantContext();
|
|
|
|
|
if (!empty($_SESSION['no_active_tenant'])) {
|
2026-03-04 15:56:58 +01:00
|
|
|
self::authService()->logout();
|
2026-02-04 23:31:53 +01:00
|
|
|
Flash::error('No active tenant assigned', 'login', 'no_active_tenant');
|
|
|
|
|
Router::redirect($redirect);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function validateCurrentTenant(): void
|
|
|
|
|
{
|
|
|
|
|
$currentTenant = $_SESSION['current_tenant'] ?? null;
|
|
|
|
|
if (!$currentTenant || empty($currentTenant['id'])) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tenantId = (int) $currentTenant['id'];
|
2026-03-04 15:56:58 +01:00
|
|
|
$tenant = self::tenantService()->findById($tenantId);
|
2026-02-04 23:31:53 +01:00
|
|
|
if ($tenant) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Current tenant was deleted — reload session so the user is switched to another active tenant or logged out.
|
2026-02-04 23:31:53 +01:00
|
|
|
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
|
|
|
|
if ($userId > 0) {
|
2026-03-04 15:56:58 +01:00
|
|
|
self::authService()->loadTenantDataIntoSession($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
} else {
|
|
|
|
|
unset($_SESSION['current_tenant']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function refreshTenantContext(): void
|
|
|
|
|
{
|
|
|
|
|
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$last = (int) ($_SESSION['tenant_context_refreshed_at'] ?? 0);
|
|
|
|
|
$now = time();
|
|
|
|
|
if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-04 15:56:58 +01:00
|
|
|
self::authService()->loadTenantDataIntoSession($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
$_SESSION['tenant_context_refreshed_at'] = $now;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
public static function requireAbility(string $ability, string $redirect = 'admin', array $context = []): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
self::requireLogin();
|
|
|
|
|
self::validateCurrentTenant();
|
|
|
|
|
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
2026-03-04 15:56:58 +01:00
|
|
|
if (!self::isAllowedByAbility($userId, $ability, $context)) {
|
2026-02-04 23:31:53 +01:00
|
|
|
Flash::error('Permission denied', $redirect, 'permission_denied');
|
|
|
|
|
Router::redirect($redirect);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
public static function requireAbilityOrForbidden(string $ability, array $context = []): void
|
|
|
|
|
{
|
|
|
|
|
self::requireAbilityDecisionOrForbidden($ability, $context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function requireAbilityDecisionOrForbidden(string $ability, array $context = []): AuthorizationDecision
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
self::requireLogin();
|
|
|
|
|
self::validateCurrentTenant();
|
|
|
|
|
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
2026-03-04 15:56:58 +01:00
|
|
|
$decision = self::authorizationService()->authorize($ability, [
|
|
|
|
|
'actor_user_id' => $userId,
|
|
|
|
|
...$context,
|
|
|
|
|
]);
|
|
|
|
|
if (!$decision->isAllowed()) {
|
2026-03-06 00:44:52 +01:00
|
|
|
// JSON requests get a 403/404 response body; browser requests get a redirect to the forbidden page.
|
2026-02-04 23:31:53 +01:00
|
|
|
if (Request::wantsJson()) {
|
2026-03-04 15:56:58 +01:00
|
|
|
http_response_code($decision->status());
|
2026-02-04 23:31:53 +01:00
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
2026-03-04 15:56:58 +01:00
|
|
|
$error = trim($decision->error());
|
|
|
|
|
echo json_encode(['error' => $error !== '' ? $error : 'forbidden']);
|
2026-02-04 23:31:53 +01:00
|
|
|
exit;
|
|
|
|
|
}
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery()));
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
2026-03-04 15:56:58 +01:00
|
|
|
|
|
|
|
|
return $decision;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function deny(string $redirect = 'error/forbidden'): void
|
|
|
|
|
{
|
|
|
|
|
if (Request::wantsJson()) {
|
|
|
|
|
http_response_code(403);
|
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
|
echo json_encode(['error' => 'forbidden']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($redirect === 'error/forbidden') {
|
|
|
|
|
Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery()));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Router::redirect($redirect);
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
private static function authorizationService(): AuthorizationService
|
2026-02-23 12:58:19 +01:00
|
|
|
{
|
2026-03-04 15:56:58 +01:00
|
|
|
if (!is_callable(self::$authorizationServiceResolver)) {
|
|
|
|
|
throw new \RuntimeException('Guard is not configured for dependency: ' . AuthorizationService::class);
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-04-14 12:54:20 +02:00
|
|
|
return (self::$authorizationServiceResolver)();
|
2026-03-04 15:56:58 +01:00
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
private static function authService(): AuthService
|
|
|
|
|
{
|
|
|
|
|
if (!is_callable(self::$authServiceResolver)) {
|
|
|
|
|
throw new \RuntimeException('Guard is not configured for dependency: ' . AuthService::class);
|
|
|
|
|
}
|
2026-04-14 12:54:20 +02:00
|
|
|
return (self::$authServiceResolver)();
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
private static function tenantService(): TenantService
|
2026-02-23 12:58:19 +01:00
|
|
|
{
|
2026-03-04 15:56:58 +01:00
|
|
|
if (!is_callable(self::$tenantServiceResolver)) {
|
|
|
|
|
throw new \RuntimeException('Guard is not configured for dependency: ' . TenantService::class);
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-04-14 12:54:20 +02:00
|
|
|
return (self::$tenantServiceResolver)();
|
2026-03-04 15:56:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function isAllowedByAbility(int $userId, string $ability, array $context = []): bool
|
|
|
|
|
{
|
|
|
|
|
$ability = trim($ability);
|
|
|
|
|
if ($userId <= 0 || $ability === '') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decision = self::authorizationService()->authorize($ability, [
|
|
|
|
|
'actor_user_id' => $userId,
|
|
|
|
|
...$context,
|
|
|
|
|
]);
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-03-04 15:56:58 +01:00
|
|
|
return $decision->isAllowed();
|
2026-02-23 12:58:19 +01:00
|
|
|
}
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|