Skip to content
Open
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ STORAGE_SOURCE=local
# See https://async-aws.com/clients/s3.html
# STORAGE_AWS_ARGS='{"endpoint": "https://s3.waw.io.cloud.ovh.net", "accessKeyId": "xxx", "accessKeySecret": "xxx", "region": "waw"}'
###< zipball storage ###

###> packeton/maintainer-groups ###
ALLOW_MAINTAINER_GROUPS=false
###< packeton/maintainer-groups ###
1 change: 1 addition & 0 deletions config/packages/packeton.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ packeton:
archive: ['mirror', 'replace']
anonymous_access: '%env(bool:PUBLIC_ACCESS)%'
anonymous_archive_access: '%env(bool:PUBLIC_ACCESS)%'
allow_maintainer_group_creation: '%env(bool:ALLOW_MAINTAINER_GROUPS)%'
archive_options:
format: zip
basedir: '%env(resolve:PACKAGIST_DIST_PATH)%'
Expand Down
4 changes: 4 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,12 @@ security:
# Maintainers
- { path: (^(/users/(.+)/packages))+, roles: ROLE_MAINTAINER }
- { path: (^(/users/(.+)/favorites))+, roles: ROLE_MAINTAINER }
- { path: ^/users/sshkey, roles: ROLE_MAINTAINER }
- { path: (^(/metadata/changes.json$|/explore|/jobs/|/archive/|/api/hooks/))+, roles: ROLE_MAINTAINER }

# Groups (maintainer access)
- { path: ^/groups, roles: ROLE_MAINTAINER }

# Secured part of the site
# This config requires being logged for the whole site and having the admin role for the admin part.
# Change these rules to adapt them to your needs
Expand Down
54 changes: 47 additions & 7 deletions src/Controller/GroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,44 @@
use Packeton\Attribute\Vars;
use Packeton\Entity\Group;
use Packeton\Form\Type\GroupType;
use Packeton\Security\Acl\GroupOwnerVoter;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class GroupController extends AbstractController
{
public function __construct(
protected ManagerRegistry $registry
protected ManagerRegistry $registry,
protected ParameterBagInterface $parameterBag,
){
}

#[Route('/groups', name: 'groups_index')]
public function indexAction(Request $request): Response
{
if (!$this->isGranted('ROLE_ADMIN')) {
if (!$this->isGranted('ROLE_MAINTAINER') || !$this->parameterBag->get('packeton.allow_maintainer_group_creation')) {
throw $this->createAccessDeniedException();
}
}

$page = $request->query->get('page', 1);
$qb = $this->registry->getRepository(Group::class)
->createQueryBuilder('g');

if (!$this->isGranted('ROLE_ADMIN') && $user = $this->getUser()) {
$qb->innerJoin('g.owners', 'o')
->andWhere('o = :user')
->setParameter('user', $user);
}

if ($searchGroup = $request->query->get('search_group')) {
$searchGroup = \mb_strtolower($searchGroup);
$qb->andWhere('LOWER(g.name) LIKE :search')
Expand All @@ -46,27 +62,51 @@ public function indexAction(Request $request): Response
return $this->render('group/index.html.twig', [
'groups' => $paginator,
'searchGroup' => $searchGroup,
'allowMaintainerGroupCreation' => $this->parameterBag->get('packeton.allow_maintainer_group_creation'),
]);
}

#[Route('/groups/create', name: 'groups_create')]
public function createAction(Request $request): Response
{
if ($this->isGranted('ROLE_ADMIN')) {
// admin can always create
} elseif (
$this->parameterBag->get('packeton.allow_maintainer_group_creation')
&& $this->isGranted('ROLE_MAINTAINER')
) {
// maintainer can create when config enabled
} else {
throw $this->createAccessDeniedException();
}

$group = new Group();
$data = $this->handleUpdate($request, $group, 'Group has been saved successfully');

if (!$this->isGranted('ROLE_ADMIN') && $user = $this->getUser()) {
$group->addOwner($user);
}

$data = $this->handleUpdate($request, $group, 'Group has been saved successfully', isAdmin: $this->isGranted('ROLE_ADMIN'));

return $data instanceof Response ? $data : $this->render('group/update.html.twig', $data);
}

#[Route('/groups/{id}/update', name: 'groups_update')]
public function updateAction(Request $request, #[Vars] Group $group): Response
{
$data = $this->handleUpdate($request, $group, 'Group has been saved successfully');
if (!$this->isGranted('ROLE_ADMIN') && !$this->parameterBag->get('packeton.allow_maintainer_group_creation')) {
throw $this->createAccessDeniedException();
}

$this->denyAccessUnlessGranted(GroupOwnerVoter::EDIT, $group);

$data = $this->handleUpdate($request, $group, 'Group has been saved successfully', isAdmin: $this->isGranted('ROLE_ADMIN'));

return $data instanceof Response ? $data : $this->render('group/update.html.twig', $data);
}

#[Route('/groups/{id}/delete', name: 'groups_delete')]
#[IsGranted('ROLE_ADMIN')]
public function deleteAction(Request $request, #[Vars] Group $group): Response
{
if (!$this->isCsrfTokenValid('delete', $request->request->get('_token'))) {
Expand All @@ -81,9 +121,9 @@ public function deleteAction(Request $request, #[Vars] Group $group): Response
return $this->redirect($this->generateUrl("groups_index"));
}

protected function handleUpdate(Request $request, Group $group, $flashMessage)
protected function handleUpdate(Request $request, Group $group, $flashMessage, bool $isAdmin = true)
{
$form = $this->createForm(GroupType::class, $group);
$form = $this->createForm(GroupType::class, $group, ['is_admin' => $isAdmin]);
if ($request->getMethod() === 'POST') {
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
Expand Down
15 changes: 13 additions & 2 deletions src/Controller/PackageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Packeton\Security\Acl\PackageManageVoter;

class PackageController extends AbstractController
{
Expand Down Expand Up @@ -575,7 +576,10 @@ public function deletePackageVersionAction(Request $req, $versionId): Response
$package = $version->getPackage();
$this->checkSubrepositoryAccess($package->getName());

if (!$package->getMaintainers()->contains($this->getUser()) && !$this->isGranted('ROLE_DELETE_PACKAGES')) {
if (!$this->isGranted(PackageManageVoter::MANAGE, $package)
&& !$package->getMaintainers()->contains($this->getUser())
&& !$this->isGranted('ROLE_DELETE_PACKAGES')
) {
throw new AccessDeniedException;
}

Expand Down Expand Up @@ -1145,7 +1149,9 @@ private function createRemoveMaintainerForm(Package $package)

private function canEditPackage(Package $package): bool
{
return $this->isGranted('ROLE_EDIT_PACKAGES') || $package->getMaintainers()->contains($this->getUser());
return $this->isGranted('ROLE_EDIT_PACKAGES')
|| $package->getMaintainers()?->contains($this->getUser())
|| $this->isGranted(PackageManageVoter::MANAGE, $package);
}

private function canDeletePackage(Package $package): bool
Expand All @@ -1154,6 +1160,11 @@ private function canDeletePackage(Package $package): bool
return false;
}

// Group owners with ACL permissions can always manage/delete
if ($this->isGranted(PackageManageVoter::MANAGE, $package)) {
return true;
}

// super admins bypass additional checks
if (!$this->isGranted('ROLE_DELETE_PACKAGES')) {
// non maintainers can not delete
Expand Down
23 changes: 16 additions & 7 deletions src/Controller/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,11 @@ public function packagesAction(Request $req, #[Vars(['name' => 'username'])] Use
#[Route('/users/sshkey/{id}', name: 'user_edit_sshkey', methods: ['GET', 'POST'])]
public function addSSHKeyAction(Request $request, #[Vars] ?SshCredentials $key = null): Response
{
if ($key && !$this->isGranted('VIEW', $key)) {
throw new AccessDeniedException();
if (!$this->isGranted('ROLE_MAINTAINER')) {
throw $this->createAccessDeniedException();
}
if ($key && !$this->isGranted('MANAGE', $key)) {
throw $this->createAccessDeniedException();
}

$sshKey = $key ?: new SshCredentials();
Expand Down Expand Up @@ -372,10 +375,17 @@ public function addSSHKeyAction(Request $request, #[Vars] ?SshCredentials $key =
}
}

$isAdmin = $this->isGranted('ROLE_ADMIN');
$listKeys = [];
if ($this->getUser() instanceof User) {
$listKeys = $this->registry->getRepository(SshCredentials::class)
->findBy(['owner' => $this->getUser()]);
$qb = $this->registry->getRepository(SshCredentials::class)
->createQueryBuilder('c');
if (!$isAdmin) {
$qb->andWhere('c.owner = :user')
->setParameter('user', $this->getUser());
}
$qb->orderBy('c.id', 'DESC');
$listKeys = $qb->getQuery()->getResult();
}

$deleteForm = $this->createFormBuilder()->getForm();
Expand All @@ -385,6 +395,7 @@ public function addSSHKeyAction(Request $request, #[Vars] ?SshCredentials $key =
'sshKey' => $sshKey,
'listKeys' => $listKeys,
'deleteForm' => $deleteForm->createView(),
'isAdmin' => $isAdmin,
]);
}

Expand All @@ -395,9 +406,7 @@ public function deleteSSHKeyAction(Request $request, #[Vars] SshCredentials $key
return new Response('Invalid csrf form', 400);
}

if (!$this->isGranted('VIEW', $key)) {
throw new AccessDeniedException();
}
$this->denyAccessUnlessGranted('MANAGE', $key);

$em = $this->registry->getManager();
$em->remove($key);
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->booleanNode('anonymous_access')->defaultFalse()->end()
->booleanNode('anonymous_archive_access')->defaultFalse()->end()
->booleanNode('allow_maintainer_group_creation')->defaultFalse()->end()
->arrayNode('web_protection')
->addDefaultsIfNotSet()
->children()
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/PacketonExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('anonymous_access', $config['anonymous_access'] ?? false);
$container->setParameter('anonymous_archive_access', $config['anonymous_archive_access'] ?? false);
$container->setParameter('packeton.allow_maintainer_group_creation', $config['allow_maintainer_group_creation'] ?? false);

$container->setParameter('packeton_github_no_api', $config['github_no_api'] ?? false);

Expand Down
49 changes: 49 additions & 0 deletions src/Entity/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,17 @@ class Group
#[ORM\OneToMany(mappedBy: "group", targetEntity: GroupAclPermission::class, cascade: ["all"], orphanRemoval: true)]
private $aclPermissions;

/**
* @var User[]|Collection
*/
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'ownedGroups')]
#[ORM\JoinTable(name: 'group_owner')]
private Collection $owners;

public function __construct()
{
$this->aclPermissions = new ArrayCollection();
$this->owners = new ArrayCollection();
}

/**
Expand Down Expand Up @@ -162,4 +170,45 @@ public function setExpiredUpdatesAt(?\DateTimeInterface $expiredUpdatesAt)
$this->expiredUpdatesAt = $expiredUpdatesAt;
return $this;
}

/**
* @return Collection|User[]
*/
public function getOwners(): Collection
{
return $this->owners;
}

/**
* @param User $user
* @return $this
*/
public function addOwner(User $user): self
{
if (!$this->owners->contains($user)) {
$this->owners->add($user);
}

return $this;
}

/**
* @param User $user
* @return $this
*/
public function removeOwner(User $user): self
{
$this->owners->removeElement($user);

return $this;
}

/**
* @param User $user
* @return bool
*/
public function hasOwner(User $user): bool
{
return $this->owners->contains($user);
}
}
16 changes: 16 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace Packeton\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Packeton\Model\BaseUser;
use Packeton\Model\PacketonUserInterface;
Expand Down Expand Up @@ -52,6 +53,12 @@ class User extends BaseUser implements PacketonUserInterface
#[ORM\InverseJoinColumn(name: 'group_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
private $groups;

/**
* @var Group[]|Collection
*/
#[ORM\ManyToMany(targetEntity: Group::class, mappedBy: 'owners')]
private Collection $ownedGroups;

#[ORM\ManyToMany(targetEntity: 'Packeton\Entity\Package', mappedBy: 'maintainers')]
private $packages;

Expand Down Expand Up @@ -87,6 +94,7 @@ public function __construct()
$this->packages = new ArrayCollection();
$this->authors = new ArrayCollection();
$this->groups = new ArrayCollection();
$this->ownedGroups = new ArrayCollection();
$this->createdAt = new \DateTime();
parent::__construct();
}
Expand Down Expand Up @@ -300,6 +308,14 @@ public function removeGroup($group)
$this->groups->removeElement($group);
}

/**
* @return Collection|Group[]
*/
public function getOwnedGroups(): Collection
{
return $this->ownedGroups;
}

/**
* Returns the user roles
*
Expand Down
Loading