0
0
Fork 0
mirror of https://github.com/verdigado/organization_folders.git synced 2024-11-23 21:20:28 +01:00

Added security classes and draft version of ResourceVoter

This commit is contained in:
Jonathan Treffler 2024-11-06 22:11:16 +01:00
parent 22c06b5689
commit 88cb258c2b
11 changed files with 428 additions and 0 deletions

View file

@ -4,5 +4,7 @@ return [
'resources' => [ 'resources' => [
], ],
'routes' => [ 'routes' => [
/* Resources */
['name' => 'resource#show', 'url' => '/resources/{resourceId}', 'verb' => 'GET'],
] ]
]; ];

View file

@ -2,14 +2,19 @@
namespace OCA\OrganizationFolders\AppInfo; namespace OCA\OrganizationFolders\AppInfo;
use Psr\Container\ContainerInterface;
use OCP\AppFramework\App; use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\IUserSession;
use OCA\DAV\Events\SabrePluginAddEvent; use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\OrganizationFolders\Listener\SabrePluginAddListener; use OCA\OrganizationFolders\Listener\SabrePluginAddListener;
use OCA\OrganizationFolders\Security\AuthorizationService;
use OCA\OrganizationFolders\Security\ResourceVoter;
class Application extends App implements IBootstrap { class Application extends App implements IBootstrap {
public const APP_ID = 'organization_folders'; public const APP_ID = 'organization_folders';
@ -20,6 +25,12 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void { public function register(IRegistrationContext $context): void {
$context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class); $context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class);
$context->registerService(AuthorizationService::class, function (ContainerInterface $c) {
$service = new AuthorizationService($c->get(IUserSession::class));
$service->registerVoter($c->get(ResourceVoter::class));
return $service;
});
} }
public function boot(IBootContext $context): void { public function boot(IBootContext $context): void {

View file

@ -0,0 +1,40 @@
<?php
namespace OCA\OrganizationFolders\Controller;
use OCA\OrganizationFolders\AppInfo\Application;
use OCA\OrganizationFolders\Errors\AccessDenied;
use OCA\OrganizationFolders\Security\AuthorizationService;
use OCP\AppFramework\Controller;
use OCP\IRequest;
class BaseController extends Controller {
private AuthorizationService $authorizationService;
public function __construct(
) {
parent::__construct(
Application::APP_ID,
\OC::$server->get(IRequest::class),
);
$this->authorizationService = \OC::$server->get(AuthorizationService::class);
}
/**
* Throws an exception unless the attributes are granted for the current authentication user and optionally
* supplied subject.
*
* @param string[] $attributes The attributes
* @param mixed $subject The subject
* @param string[] $attributes Attributes of subject
* @param string $message The message passed to the exception
*
* @throws AccessDenied
*/
protected function denyAccessUnlessGranted(array $attributes, $subject, $message = 'Access Denied.') {
if (!$this->authorizationService->isGranted($attributes, $subject)) {
throw new AccessDenied($message);
}
}
}

35
lib/Controller/Errors.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace OCA\OrganizationFolders\Controller;
use Closure;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCA\OrganizationFolders\Errors\NotFoundException;
trait Errors {
private function errorResponse(\Exception $e, $status = Http::STATUS_BAD_REQUEST): JSONResponse {
$response = ['error' => get_class($e), 'message' => $e->getMessage()];
return new JSONResponse($response, $status);
}
protected function handleNotFound(Closure $callback): JSONResponse {
try {
return new JSONResponse($callback());
} catch (NotFoundException $e) {
return $this->errorResponse($e, Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
return $this->errorResponse($e);
}
}
protected function handleErrors(Closure $callback): JSONResponse {
try {
return new JSONResponse($callback());
} catch (\Exception $e) {
return $this->errorResponse($e);
}
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace OCA\OrganizationFolders\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCA\OrganizationFolders\Service\ResourceService;
class ResourceController extends BaseController {
use Errors;
public function __construct(
private ResourceService $service,
private string $userId,
) {
parent::__construct();
}
#[NoAdminRequired]
public function show(int $resourceId): JSONResponse {
return $this->handleNotFound(function () use ($resourceId) {
$resource = $this->service->find($resourceId);
$this->denyAccessUnlessGranted(['READ'], $resource);
return $resource;
});
}
#[NoAdminRequired]
public function create(
int $organizationFolderId,
string $type,
string $name,
?int $parentResourceId = null,
bool $active = true,
bool $inheritManagers = true,
// for type folder
?int $membersAclPermission = null,
?int $managersAclPermission = null,
?int $inheritedAclPermission = null,
): JSONResponse {
return $this->handleErrors(function () use ($organizationFolderId, $type, $name, $parentResourceId, $active, $inheritManagers, $membersAclPermission, $managersAclPermission, $inheritedAclPermission) {
// TODO: check permissions
return $this->service->create(
organizationFolderId: $organizationFolderId,
type: $type,
name: $name,
parentResourceId: $parentResourceId,
active: $active,
inheritManagers: $inheritManagers,
membersAclPermission: $membersAclPermission,
managersAclPermission: $managersAclPermission,
inheritedAclPermission: $inheritedAclPermission,
);
});
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace OCA\OrganizationFolders\Errors;
class AccessDenied extends \RuntimeException {
public function __construct(string $message = 'Access Denied.', \Throwable $previous = null) {
parent::__construct($message, 403, $previous);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace OCA\OrganizationFolders\Security;
class AffirmativeStrategy implements \Stringable {
private bool $allowIfAllAbstainDecisions;
public function __construct(bool $allowIfAllAbstainDecisions = false) {
$this->allowIfAllAbstainDecisions = $allowIfAllAbstainDecisions;
}
public function decide(\Traversable $results): bool {
$deny = 0;
foreach ($results as $result) {
if (VoterInterface::ACCESS_GRANTED === $result) {
return true;
}
if (VoterInterface::ACCESS_DENIED === $result) {
++$deny;
}
}
if ($deny > 0) {
return false;
}
return $this->allowIfAllAbstainDecisions;
}
public function __toString(): string {
return 'affirmative';
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace OCA\OrganizationFolders\Security;
use OCP\IUserSession;
class AuthorizationService {
private const VALID_VOTES = [
VoterInterface::ACCESS_GRANTED => true,
VoterInterface::ACCESS_DENIED => true,
VoterInterface::ACCESS_ABSTAIN => true,
];
/**
* @var Voter[]
*/
private array $voters = [];
private $strategy;
public function __construct(private IUserSession $userSession) {
$this->strategy = new AffirmativeStrategy();
}
public function registerVoter(Voter $voter): self {
$this->voters[] = $voter;
return $this;
}
public function isGranted(array $attributes, $subject) {
return $this->strategy->decide(
$this->collectResults($attributes, $subject)
);
}
private function collectResults(array $attributes, $subject): \Traversable {
$user = $this->userSession->getUser();
foreach ($this->voters as $voter) {
$result = $voter->vote($user, $subject, $attributes);
if (!\is_int($result) || !(self::VALID_VOTES[$result] ?? false)) {
throw new \LogicException(sprintf('"%s::vote()" must return one of "%s" constants ("ACCESS_GRANTED", "ACCESS_DENIED" or "ACCESS_ABSTAIN"), "%s" returned.', get_debug_type($voter), VoterInterface::class, var_export($result, true)));
}
yield $result;
}
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace OCA\OrganizationFolders\Security;
use OCP\IUser;
use OCP\IGroupManager;
use OCA\OrganizationFolders\Db\Resource;
use OCA\OrganizationFolders\Service\ResourceService;
use OCA\OrganizationFolders\Service\ResourceMemberService;
use OCA\OrganizationFolders\Enum\MemberPermissionLevel;
use OCA\OrganizationFolders\Enum\MemberType;
use OCA\OrganizationFolders\OrganizationProvider\OrganizationProviderManager;
class ResourceVoter extends Voter {
public function __construct(
private ResourceService $resourceService,
private ResourceMemberService $resourceMemberService,
private IGroupManager $groupManager,
private OrganizationProviderManager $organizationProviderManager,
) {
}
protected function supports(string $attribute, mixed $subject): bool {
return $subject instanceof Resource || $subject === Resource::class;
}
protected function voteOnAttribute(string $attribute, mixed $subject, ?IUser $user): bool {
// _dlog($attribute, $subject);
if (!$user) {
return false;
}
/** @var Resource */
$resource = $subject;
return match ($attribute) {
'READ' => $this->isGranted($user, $resource),
'UPDATE' => $this->isGranted($user, $resource),
'DELETE' => $this->isGranted($user, $resource),
default => throw new \LogicException('This code should not be reached!')
};
}
private function isResourceOrganizationFolderAdmin(IUser $user, Resource $resource): bool {
// TODO: implement
return false;
}
/**
* @param IUser $user
* @param Resource $resource
* @return bool
*/
private function isResourceManager(IUser $user, Resource $resource): bool {
// TODO: check if is top-level resource and user is organizationFolder manager
$resourceMembers = $this->resourceMemberService->findAll($resource->getId());
foreach($resourceMembers as $resourceMember) {
if($resourceMember->getPermissionLevel() === MemberPermissionLevel::MANAGER->value) {
if($resourceMember->getType() === MemberType::USER->value) {
if($resourceMember->getPrincipal() === $user->getUID()) {
return true;
}
} else if($resourceMember->getType() === MemberType::GROUP->value) {
if($this->groupManager->isInGroup($user->getUID(), $resourceMember->getPrincipal())) {
return true;
}
} else if($resourceMember->getType() === MemberType::ROLE->value) {
['organizationProviderId' => $organizationProviderId, 'roleId' => $roleId] = $resourceMember->getParsedPrincipal();
$organizationProvider = $this->organizationProviderManager->getOrganizationProvider($organizationProviderId);
$role = $organizationProvider->getRole($roleId);
if($this->groupManager->isInGroup($user->getUID(), $role->getMembersGroup())) {
return true;
}
}
}
}
if($resource->getInheritManagers()) {
$parentResource = $this->resourceService->getParentResource($resource);
if(!is_null($parentResource)) {
return $this->isResourceManager($user, $resource);
}
}
return false;
}
protected function isGranted(IUser $user, Resource $resource): bool {
return $this->isResourceOrganizationFolderAdmin($user, $resource) || $this->isResourceManager($user, $resource);
}
}

67
lib/Security/Voter.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace OCA\OrganizationFolders\Security;
use OCP\IUser;
abstract class Voter implements VoterInterface {
public function vote(?IUser $user, mixed $subject, array $attributes): int {
// abstain vote by default in case none of the attributes are supported
$vote = self::ACCESS_ABSTAIN;
foreach ($attributes as $attribute) {
try {
if (!$this->supports($attribute, $subject)) {
continue;
}
} catch (\TypeError $e) {
if (str_contains($e->getMessage(), 'supports(): Argument #1')) {
continue;
}
throw $e;
}
// as soon as at least one attribute is supported, default is to deny access
$vote = self::ACCESS_DENIED;
if ($this->voteOnAttribute($attribute, $subject, $user)) {
// grant access as soon as at least one attribute returns a positive response
return self::ACCESS_GRANTED;
}
}
return $vote;
}
/**
* Return false if your voter doesn't support the given attribute. Symfony will cache
* that decision and won't call your voter again for that attribute.
*/
public function supportsAttribute(string $attribute): bool {
return true;
}
/**
* Return false if your voter doesn't support the given subject type. Symfony will cache
* that decision and won't call your voter again for that subject type.
*
* @param string $subjectType The type of the subject inferred by `get_class()` or `get_debug_type()`
*/
public function supportsType(string $subjectType): bool {
return true;
}
/**
* Determines if the attribute and subject are supported by this voter.
*
* @param $subject The subject to secure, e.g. an object the user wants to access or any other PHP type
*/
abstract protected function supports(string $attribute, mixed $subject): bool;
/**
* Perform a single access check operation on a given attribute, subject and token.
* It is safe to assume that $attribute and $subject already passed the "supports()" method check.
*/
abstract protected function voteOnAttribute(string $attribute, mixed $subject, ?IUser $user): bool;
}

View file

@ -0,0 +1,24 @@
<?php
namespace OCA\OrganizationFolders\Security;
use OCP\IUser;
interface VoterInterface {
public const ACCESS_GRANTED = 1;
public const ACCESS_ABSTAIN = 0;
public const ACCESS_DENIED = -1;
/**
* Returns the vote for the given parameters.
*
* This method must return one of the following constants:
* ACCESS_GRANTED, ACCESS_DENIED, or ACCESS_ABSTAIN.
*
* @param mixed $subject The subject to secure
* @param array $attributes An array of attributes associated with the method being invoked
*
* @return int either ACCESS_GRANTED, ACCESS_ABSTAIN, or ACCESS_DENIED
*/
public function vote(?IUser $user, mixed $subject, array $attributes): int;
}