From 88cb258c2b386fdacc6877022e5e70abed4a59b2 Mon Sep 17 00:00:00 2001 From: Jonathan Treffler Date: Wed, 6 Nov 2024 22:11:16 +0100 Subject: [PATCH] Added security classes and draft version of ResourceVoter --- appinfo/routes.php | 2 + lib/AppInfo/Application.php | 11 +++ lib/Controller/BaseController.php | 40 +++++++++++ lib/Controller/Errors.php | 35 ++++++++++ lib/Controller/ResourceController.php | 62 +++++++++++++++++ lib/Errors/AccessDenied.php | 9 +++ lib/Security/AffirmativeStrategy.php | 34 ++++++++++ lib/Security/AuthorizationService.php | 48 ++++++++++++++ lib/Security/ResourceVoter.php | 96 +++++++++++++++++++++++++++ lib/Security/Voter.php | 67 +++++++++++++++++++ lib/Security/VoterInterface.php | 24 +++++++ 11 files changed, 428 insertions(+) create mode 100644 lib/Controller/BaseController.php create mode 100644 lib/Controller/Errors.php create mode 100644 lib/Controller/ResourceController.php create mode 100644 lib/Errors/AccessDenied.php create mode 100644 lib/Security/AffirmativeStrategy.php create mode 100644 lib/Security/AuthorizationService.php create mode 100644 lib/Security/ResourceVoter.php create mode 100644 lib/Security/Voter.php create mode 100644 lib/Security/VoterInterface.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 4aca6c4..20b2974 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -4,5 +4,7 @@ return [ 'resources' => [ ], 'routes' => [ + /* Resources */ + ['name' => 'resource#show', 'url' => '/resources/{resourceId}', 'verb' => 'GET'], ] ]; \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1235b87..7522f9a 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -2,14 +2,19 @@ namespace OCA\OrganizationFolders\AppInfo; +use Psr\Container\ContainerInterface; + use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\IUserSession; use OCA\DAV\Events\SabrePluginAddEvent; use OCA\OrganizationFolders\Listener\SabrePluginAddListener; +use OCA\OrganizationFolders\Security\AuthorizationService; +use OCA\OrganizationFolders\Security\ResourceVoter; class Application extends App implements IBootstrap { public const APP_ID = 'organization_folders'; @@ -20,6 +25,12 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $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 { diff --git a/lib/Controller/BaseController.php b/lib/Controller/BaseController.php new file mode 100644 index 0000000..7712bd8 --- /dev/null +++ b/lib/Controller/BaseController.php @@ -0,0 +1,40 @@ +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); + } + } +} diff --git a/lib/Controller/Errors.php b/lib/Controller/Errors.php new file mode 100644 index 0000000..6d5248c --- /dev/null +++ b/lib/Controller/Errors.php @@ -0,0 +1,35 @@ + 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); + } + } +} \ No newline at end of file diff --git a/lib/Controller/ResourceController.php b/lib/Controller/ResourceController.php new file mode 100644 index 0000000..5c09b2b --- /dev/null +++ b/lib/Controller/ResourceController.php @@ -0,0 +1,62 @@ +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, + ); + }); + } +} \ No newline at end of file diff --git a/lib/Errors/AccessDenied.php b/lib/Errors/AccessDenied.php new file mode 100644 index 0000000..518fa3a --- /dev/null +++ b/lib/Errors/AccessDenied.php @@ -0,0 +1,9 @@ +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'; + } +} diff --git a/lib/Security/AuthorizationService.php b/lib/Security/AuthorizationService.php new file mode 100644 index 0000000..6d1f62b --- /dev/null +++ b/lib/Security/AuthorizationService.php @@ -0,0 +1,48 @@ + 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; + } + } +} diff --git a/lib/Security/ResourceVoter.php b/lib/Security/ResourceVoter.php new file mode 100644 index 0000000..bd3a1b1 --- /dev/null +++ b/lib/Security/ResourceVoter.php @@ -0,0 +1,96 @@ + $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); + } +} diff --git a/lib/Security/Voter.php b/lib/Security/Voter.php new file mode 100644 index 0000000..54b708c --- /dev/null +++ b/lib/Security/Voter.php @@ -0,0 +1,67 @@ +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; +} diff --git a/lib/Security/VoterInterface.php b/lib/Security/VoterInterface.php new file mode 100644 index 0000000..3289936 --- /dev/null +++ b/lib/Security/VoterInterface.php @@ -0,0 +1,24 @@ +