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:
parent
22c06b5689
commit
88cb258c2b
11 changed files with 428 additions and 0 deletions
|
@ -4,5 +4,7 @@ return [
|
||||||
'resources' => [
|
'resources' => [
|
||||||
],
|
],
|
||||||
'routes' => [
|
'routes' => [
|
||||||
|
/* Resources */
|
||||||
|
['name' => 'resource#show', 'url' => '/resources/{resourceId}', 'verb' => 'GET'],
|
||||||
]
|
]
|
||||||
];
|
];
|
|
@ -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 {
|
||||||
|
|
40
lib/Controller/BaseController.php
Normal file
40
lib/Controller/BaseController.php
Normal 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
35
lib/Controller/Errors.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
62
lib/Controller/ResourceController.php
Normal file
62
lib/Controller/ResourceController.php
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
lib/Errors/AccessDenied.php
Normal file
9
lib/Errors/AccessDenied.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
34
lib/Security/AffirmativeStrategy.php
Normal file
34
lib/Security/AffirmativeStrategy.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
48
lib/Security/AuthorizationService.php
Normal file
48
lib/Security/AuthorizationService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
lib/Security/ResourceVoter.php
Normal file
96
lib/Security/ResourceVoter.php
Normal 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
67
lib/Security/Voter.php
Normal 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;
|
||||||
|
}
|
24
lib/Security/VoterInterface.php
Normal file
24
lib/Security/VoterInterface.php
Normal 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;
|
||||||
|
}
|
Loading…
Reference in a new issue