Files
nextcloud-forum/lib/Middleware/PermissionMiddleware.php

250 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Middleware;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Service\PermissionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Middleware;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\Services\IAppConfig;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use ReflectionMethod;
/**
* Middleware to enforce permission checks based on RequirePermission attributes
*/
class PermissionMiddleware extends Middleware {
public function __construct(
private IRequest $request,
private IUserSession $userSession,
private PermissionService $permissionService,
private IAppConfig $config,
private LoggerInterface $logger,
) {
}
/**
* Check permissions before controller method is called
*
* @param Controller $controller
* @param string $methodName
* @throws OCSForbiddenException If user lacks required permissions
*/
public function beforeController($controller, $methodName): void {
$reflectionMethod = new ReflectionMethod($controller, $methodName);
// Check if this is a public page - allows unauthenticated access but still enforces permissions
$publicPageAttrs = $reflectionMethod->getAttributes(PublicPage::class);
$isPublicPage = !empty($publicPageAttrs);
$user = $this->userSession->getUser();
$userId = $user ? $user->getUID() : null;
$guestAccessEnabled = $this->config->getAppValueBool('allow_guest_access', false, true);
$isReadOnlyMethod = in_array($this->request->getMethod(), ['GET', 'HEAD', 'OPTIONS']);
// If user is not authenticated
if (!$user) {
// Allow unauthenticated access for public pages or when guest access is enabled for read-only
$allowUnauthenticated = $isPublicPage || $guestAccessEnabled;
if ($allowUnauthenticated) {
// Check if there are permission requirements - if so, check guest permissions
$permissionAttrs = $reflectionMethod->getAttributes(RequirePermission::class);
if (!empty($permissionAttrs)) {
// Check permissions using guest role (null userId)
$this->logger->debug('Checking permissions for unauthenticated user (public page or guest access)');
$this->checkPermissions(null, $permissionAttrs);
}
return;
}
// Guest access not enabled or not a read-only method - deny access
$this->logger->debug('Permission check failed: User not authenticated');
throw new OCSForbiddenException('User not authenticated');
}
// User is authenticated - check permissions normally
$permissionAttrs = $reflectionMethod->getAttributes(RequirePermission::class);
if (empty($permissionAttrs)) {
// No permission requirements, allow access
return;
}
$this->checkPermissions($userId, $permissionAttrs);
}
/**
* Check all permission attributes, respecting OR groups.
*
* Attributes with the same orGroup are OR'd (any one must pass).
* Attributes with no orGroup, or with different orGroups, are AND'd together.
*
* @param string|null $userId
* @param \ReflectionAttribute[] $permissionAttrs
* @throws OCSForbiddenException
*/
private function checkPermissions(?string $userId, array $permissionAttrs): void {
// Separate into OR groups and ungrouped
$orGroups = [];
$ungrouped = [];
foreach ($permissionAttrs as $attr) {
/** @var RequirePermission $permission */
$permission = $attr->newInstance();
$group = $permission->getOrGroup();
if ($group !== null) {
$orGroups[$group][] = $permission;
} else {
$ungrouped[] = $permission;
}
}
// Ungrouped attributes: all must pass (AND)
foreach ($ungrouped as $permission) {
$this->checkPermission($userId, $permission);
}
// OR groups: at least one in each group must pass
foreach ($orGroups as $group => $permissions) {
$lastException = null;
$passed = false;
foreach ($permissions as $permission) {
try {
$this->checkPermission($userId, $permission);
$passed = true;
break;
} catch (OCSForbiddenException $e) {
$lastException = $e;
}
}
if (!$passed && $lastException !== null) {
throw $lastException;
}
}
}
/**
* Check a single permission requirement
*
* @param string|null $userId Nextcloud user ID (null for guest users)
* @param RequirePermission $permission Permission requirement to check
* @throws OCSForbiddenException If permission check fails
*/
private function checkPermission(?string $userId, RequirePermission $permission): void {
$permissionName = $permission->getPermission();
$resourceType = $permission->getResourceType();
// Global permission check
if ($resourceType === null) {
if (!$this->permissionService->hasGlobalPermission($userId, $permissionName)) {
$this->logger->info("User $userId denied access: lacks global permission '$permissionName'");
throw new OCSForbiddenException("Insufficient permissions: $permissionName");
}
return;
}
// Resource-specific permission check
try {
$resourceId = $this->resolveResourceId($permission);
if ($resourceType === 'category') {
if (!$this->permissionService->hasCategoryPermission($userId, $resourceId, $permissionName)) {
$this->logger->info("User $userId denied access: lacks category permission '$permissionName' on category $resourceId");
throw new OCSForbiddenException("Insufficient category permissions: $permissionName");
}
} else {
$this->logger->warning("Unknown resource type: $resourceType");
throw new OCSForbiddenException('Invalid permission configuration');
}
} catch (\InvalidArgumentException $e) {
$this->logger->error('Failed to resolve resource ID: ' . $e->getMessage());
throw new OCSForbiddenException('Invalid request: cannot determine resource');
} catch (\Exception $e) {
$this->logger->error('Permission check error: ' . $e->getMessage());
throw new OCSForbiddenException('Permission check failed');
}
}
/**
* Resolve resource ID from request based on permission configuration
*
* @param RequirePermission $permission Permission configuration
* @return int Resolved resource ID
* @throws \InvalidArgumentException If resource ID cannot be resolved
*/
private function resolveResourceId(RequirePermission $permission): int {
// From route parameter (e.g., /api/categories/{id})
if ($param = $permission->getResourceIdParam()) {
$value = $this->request->getParam($param);
if ($value !== null) {
return (int)$value;
}
throw new \InvalidArgumentException("Route parameter '$param' not found");
}
// From request body (e.g., POST {categoryId: 5})
if ($body = $permission->getResourceIdBody()) {
$data = $this->request->getParams();
if (isset($data[$body])) {
return (int)$data[$body];
}
throw new \InvalidArgumentException("Request body parameter '$body' not found");
}
// Derive category ID from thread ID
if ($threadParam = $permission->getResourceIdFromThreadId()) {
$threadId = $this->request->getParam($threadParam);
if ($threadId !== null) {
return $this->permissionService->getCategoryIdFromThread((int)$threadId);
}
throw new \InvalidArgumentException("Thread ID parameter '$threadParam' not found");
}
// Derive category ID from post ID
if ($postParam = $permission->getResourceIdFromPostId()) {
$postId = $this->request->getParam($postParam);
if ($postId !== null) {
return $this->permissionService->getCategoryIdFromPost((int)$postId);
}
throw new \InvalidArgumentException("Post ID parameter '$postParam' not found");
}
throw new \InvalidArgumentException('Cannot resolve resource ID: no valid parameter configuration');
}
/**
* Handle exceptions thrown in beforeController
*
* @param Controller $controller
* @param string $methodName
* @param \Exception $exception
* @return Response
* @throws \Exception
*/
public function afterException($controller, $methodName, \Exception $exception): Response {
// Re-throw OCS exceptions (they'll be handled by the framework)
if ($exception instanceof OCSException) {
throw $exception;
}
// Log and re-throw other exceptions
$this->logger->error('Unexpected exception in PermissionMiddleware: ' . $exception->getMessage(), [
'exception' => $exception,
]);
throw $exception;
}
}