User roles and permission management via Admin UI (#1231)

This commit is contained in:
Kevin Papst
2019-11-10 18:53:56 +01:00
committed by GitHub
parent c6c4098759
commit af0f89774e
58 changed files with 1355 additions and 186 deletions

2
.gitignore vendored
View File

@@ -5,7 +5,9 @@
/bin/* /bin/*
!bin/console !bin/console
/config/packages/local.yaml /config/packages/local.yaml
/config/packages/*-local.yaml
/config/packages/*/local.yaml /config/packages/*/local.yaml
/config/packages/*/*-local.yaml
public/avatars/*.png public/avatars/*.png

View File

@@ -8,6 +8,15 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process. Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
- New functionality to manage permissions via Admin UI were added. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html)
- New database tables were created, don't forget to [run the migrations](https://www.kimai.org/documentation/updates.html)
### Developer
Please add default permissions to your [plugin](https://www.kimai.org/documentation/plugins.html).
## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5) ## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
[Update as usual](https://www.kimai.org/documentation/updates.html) [Update as usual](https://www.kimai.org/documentation/updates.html)

View File

@@ -33,6 +33,7 @@ import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
import KimaiFormSelect from "./plugins/KimaiFormSelect"; import KimaiFormSelect from "./plugins/KimaiFormSelect";
import KimaiForm from "./plugins/KimaiForm"; import KimaiForm from "./plugins/KimaiForm";
import KimaiDatePicker from "./plugins/KimaiDatePicker"; import KimaiDatePicker from "./plugins/KimaiDatePicker";
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
export default class KimaiLoader { export default class KimaiLoader {
@@ -49,6 +50,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiAPI()); kimai.registerPlugin(new KimaiAPI());
kimai.registerPlugin(new KimaiAlert()); kimai.registerPlugin(new KimaiAlert());
kimai.registerPlugin(new KimaiFormSelect('.selectpicker')); kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]')); kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility')); kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]')); kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));

View File

@@ -5,7 +5,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTableRow"; import KimaiPlugin from "../KimaiPlugin";
/** /**
* Needs to be initialized with a class name. * Needs to be initialized with a class name.
@@ -18,7 +18,7 @@ import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTabl
* *
* @param selector * @param selector
*/ */
export default class KimaiAPILink extends KimaiClickHandlerReducedInTableRow { export default class KimaiAPILink extends KimaiPlugin {
constructor(selector) { constructor(selector) {
super(); super();

View File

@@ -0,0 +1,55 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import KimaiPlugin from "../KimaiPlugin";
/**
* Needs to be initialized with a class name.
*
* Allows to assign the given selector to any element, which then is used as click-handler
* calling an API method and trigger the event from data-event attribute afterwards.
*
* @param selector
*/
export default class KimaiConfirmationLink extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
init() {
const self = this;
document.addEventListener('click', function(event) {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(self.selector)) {
const attributes = target.dataset;
let url = attributes['href'];
if (!url) {
url = target.getAttribute('href');
}
if (attributes.question !== undefined) {
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
if (value) {
document.location = url;
}
});
}
event.preventDefault();
event.stopPropagation();
}
target = target.parentNode;
}
});
}
}

View File

@@ -175,7 +175,7 @@ kimai:
user_duration: user_duration:
title: stats.yourWorkingHours title: stats.yourWorkingHours
order: 10 order: 10
permission: ROLE_USER permission: view_own_timesheet
type: '\App\Widget\Type\CompoundChart' type: '\App\Widget\Type\CompoundChart'
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear] widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates: user_rates:

View File

@@ -48,6 +48,7 @@ security:
allow_if_all_abstain: false allow_if_all_abstain: false
role_hierarchy: role_hierarchy:
ROLE_USER: ~
ROLE_TEAMLEAD: ROLE_USER ROLE_TEAMLEAD: ROLE_USER
ROLE_ADMIN: ROLE_TEAMLEAD ROLE_ADMIN: ROLE_TEAMLEAD
ROLE_SUPER_ADMIN: ROLE_ADMIN ROLE_SUPER_ADMIN: ROLE_ADMIN

View File

@@ -1,6 +1,6 @@
app.test.sidebar_settings: app.test.active_entries:
path: /{_locale}/sidebar/settings path: /{_locale}/layou/active_entries
controller: App\Controller\SidebarController::settingsAction controller: App\Controller\LayoutController::activeEntries
requirements: requirements:
_locale: '%app_locales%' _locale: '%app_locales%'
defaults: defaults:

View File

@@ -135,7 +135,8 @@ services:
# ================================================================================ # ================================================================================
App\Security\RoleService: App\Security\RoleService:
arguments: ['%security.role_hierarchy.roles%'] arguments:
$roles: '%security.role_hierarchy.roles%'
App\Security\RolePermissionManager: App\Security\RolePermissionManager:
arguments: arguments:
@@ -198,6 +199,16 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Configuration'] arguments: ['App\Entity\Configuration']
App\Repository\RoleRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Role']
App\Repository\RolePermissionRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\RolePermission']
App\Repository\InvoiceDocumentRepository: App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%'] arguments: ['%kimai.invoice.documents%']

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.4ee6be68.js", "build/runtime.4ee6be68.js",
"build/0.a87622f3.js", "build/0.a87622f3.js",
"build/1.c1bee41f.js", "build/1.c1bee41f.js",
"build/app.52968b5f.js" "build/app.f4954bf5.js"
], ],
"css": [ "css": [
"build/app.4bdd4f2d.css" "build/app.4bdd4f2d.css"
@@ -35,7 +35,7 @@
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok", "build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI", "build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.52968b5f.js": "sha384-yWBI0DIyKu0YrNSDgjIfdCEAEyw+d7qiiEVnY/AfDWljVigYuBlrHnBYsYdMccNT", "build/app.f4954bf5.js": "sha384-7g3VJTabvDpytWUXASpINBO3z2Rsaum700b4qzbf30TryOoFJeMfzsOC/jxMCxXj",
"build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm", "build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q", "build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.c1bee41f.js": "build/1.c1bee41f.js", "build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js", "build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.4bdd4f2d.css", "build/app.css": "build/app.4bdd4f2d.css",
"build/app.js": "build/app.52968b5f.js", "build/app.js": "build/app.f4954bf5.js",
"build/calendar.css": "build/calendar.36ef2a02.css", "build/calendar.css": "build/calendar.36ef2a02.css",
"build/calendar.js": "build/calendar.6f94d071.js", "build/calendar.js": "build/calendar.6f94d071.js",
"build/chart.js": "build/chart.0af3f813.js", "build/chart.js": "build/chart.0af3f813.js",

View File

@@ -38,7 +38,7 @@ trait StringAccessibleConfigTrait
*/ */
protected function getConfigurations(ConfigLoaderInterface $repository): array protected function getConfigurations(ConfigLoaderInterface $repository): array
{ {
return $repository->getConfiguration($this->getPrefix() . '.'); return $repository->getConfiguration($this->getPrefix());
} }
protected function prepare() protected function prepare()

View File

@@ -27,7 +27,10 @@ abstract class AbstractController extends BaseAbstractController implements Serv
public const DOMAIN_FLASH = 'flashmessages'; public const DOMAIN_FLASH = 'flashmessages';
public const DOMAIN_ERROR = 'exceptions'; public const DOMAIN_ERROR = 'exceptions';
public const ROLE_ADMIN = 'ROLE_ADMIN'; /**
* @deprecated since 1.6, will be removed with 2.0
*/
public const ROLE_ADMIN = User::ROLE_ADMIN;
/** /**
* @return DataCollectorTranslator * @return DataCollectorTranslator

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to display calendars. * Controller used to display calendars.
* *
* @Route(path="/calendar") * @Route(path="/calendar")
* @Security("is_granted('ROLE_USER')") * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/ */
class CalendarController extends AbstractController class CalendarController extends AbstractController
{ {

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Dashboard controller for the admin area. * Dashboard controller for the admin area.
* *
* @Route(path="/dashboard") * @Route(path="/dashboard")
* @Security("is_granted('ROLE_USER')") * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/ */
class DashboardController extends AbstractController class DashboardController extends AbstractController
{ {
@@ -121,9 +121,16 @@ class DashboardController extends AbstractController
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
$sections = $event->getSections(); $sections = $event->getSections();
$clearedSections = [];
/** @var WidgetContainerInterface $section */
foreach ($sections as $key => $section) {
if (!empty($section->getWidgets())) {
$clearedSections[] = $section;
}
}
uasort( uasort(
$sections, $clearedSections,
function (WidgetContainerInterface $a, WidgetContainerInterface $b) { function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
if ($a->getOrder() == $b->getOrder()) { if ($a->getOrder() == $b->getOrder()) {
return 0; return 0;
@@ -134,7 +141,7 @@ class DashboardController extends AbstractController
); );
return $this->render('dashboard/index.html.twig', [ return $this->render('dashboard/index.html.twig', [
'widgets' => $sections 'widgets' => $clearedSections
]); ]);
} }
} }

View File

@@ -20,7 +20,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Homepage controller is a redirect controller with user specific logic. * Homepage controller is a redirect controller with user specific logic.
* *
* @Route(path="/homepage") * @Route(path="/homepage")
* @Security("is_granted('ROLE_USER')") * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/ */
class HomepageController extends AbstractController class HomepageController extends AbstractController
{ {

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Configuration\TimesheetConfiguration;
use App\Repository\TimesheetRepository;
use Symfony\Component\HttpFoundation\Response;
/**
* Used for the (initial) page rendering.
*/
class LayoutController extends AbstractController
{
public function activeEntries(TimesheetRepository $repository, TimesheetConfiguration $configuration): Response
{
$user = $this->getUser();
$activeEntries = $repository->getActiveEntries($user);
return $this->render(
'navbar/active-entries.html.twig',
[
'entries' => $activeEntries,
'soft_limit' => $configuration->getActiveEntriesSoftLimit(),
]
);
}
}

View File

@@ -0,0 +1,161 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\Role;
use App\Entity\RolePermission;
use App\Form\RoleType;
use App\Repository\RolePermissionRepository;
use App\Repository\RoleRepository;
use App\Security\RolePermissionManager;
use App\Security\RoleService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage user roles and role permissions.
*
* @Route(path="/admin/permissions")
* @Security("is_granted('role_permissions')")
*/
final class PermissionController extends AbstractController
{
/**
* @var RoleService
*/
private $roleService;
/**
* @var RolePermissionManager
*/
private $manager;
/**
* @var RoleRepository
*/
private $roleRepository;
public function __construct(RoleService $roleService, RolePermissionManager $manager, RoleRepository $roleRepository)
{
$this->roleService = $roleService;
$this->manager = $manager;
$this->roleRepository = $roleRepository;
}
/**
* @Route(path="", name="admin_user_permissions", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
public function permissions()
{
$all = $this->roleRepository->findAll();
$existing = [];
foreach ($all as $role) {
$existing[] = $role->getName();
}
$existing = array_map('strtoupper', $existing);
// automatically import all hard coded (default) roles into the database table
foreach ($this->roleService->getAvailableNames() as $roleName) {
$roleName = strtoupper($roleName);
if (!in_array($roleName, $existing)) {
$role = new Role();
$role->setName($roleName);
$this->roleRepository->saveRole($role);
$existing[] = $roleName;
}
}
return $this->render('user/permissions.html.twig', [
'roles' => $this->roleRepository->findAll(),
'permissions' => $this->manager->getPermissions(),
'manager' => $this->manager,
'system_roles' => $this->roleService->getSystemRoles(),
]);
}
/**
* @Route(path="/roles/create", name="admin_user_roles", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
public function createRole(Request $request): Response
{
$role = new Role();
$form = $this->createForm(RoleType::class, $role, [
'action' => $this->generateUrl('admin_user_roles', []),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->roleRepository->saveRole($role);
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashSuccess('action.update.error');
}
return $this->redirectToRoute('admin_user_permissions');
}
return $this->render('user/edit_role.html.twig', [
'form' => $form->createView(),
'role' => $role,
]);
}
/**
* @Route(path="/roles/{id}/delete", name="admin_user_role_delete", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*/
public function deleteRole(Role $role): Response
{
try {
$this->roleRepository->deleteRole($role);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_user_permissions');
}
/**
* @Route(path="/roles/{id}/{name}/{value}", name="admin_user_permission_save", methods={"GET"})
* @Security("is_granted('role_permissions')")
*/
public function savePermission(Role $role, string $name, string $value, RolePermissionRepository $rolePermissionRepository): Response
{
if (!$this->manager->isRegisteredPermission($name)) {
throw $this->createNotFoundException('Unknown permission: ' . $name);
}
try {
$permission = $rolePermissionRepository->findRolePermission($role, $name);
if (null === $permission) {
$permission = new RolePermission();
$permission->setRole($role);
$permission->setPermission($name);
}
$permission->setAllowed((bool) $value);
$rolePermissionRepository->saveRolePermission($permission);
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('action.update.error');
}
return $this->redirectToRoute('admin_user_permissions');
}
}

View File

@@ -78,23 +78,4 @@ class TimesheetController extends TimesheetAbstractController
{ {
return $this->create($request, 'timesheet/edit.html.twig', $projectRepository, $activityRepository, $tagRepository); return $this->create($request, 'timesheet/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
} }
/**
* Used for the initial page rendering.
*
* @return Response
*/
public function activeEntriesAction()
{
$user = $this->getUser();
$activeEntries = $this->getRepository()->getActiveEntries($user);
return $this->render(
'navbar/active-entries.html.twig',
[
'entries' => $activeEntries,
'soft_limit' => $this->getSoftLimit(),
]
);
}
} }

View File

@@ -16,11 +16,12 @@ use App\Form\UserCreateType;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Security\RolePermissionManager;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -64,12 +65,8 @@ class UserController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"}) * @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
* @Security("is_granted('view_user')") * @Security("is_granted('view_user')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request): Response
{ {
$query = new UserQuery(); $query = new UserQuery();
$query->setPage($page); $query->setPage($page);
@@ -99,11 +96,8 @@ class UserController extends AbstractController
/** /**
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"}) * @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
* @Security("is_granted('create_user')") * @Security("is_granted('create_user')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/ */
public function createAction(Request $request) public function createAction(Request $request): Response
{ {
$user = new User(); $user = new User();
$user->setEnabled(true); $user->setEnabled(true);
@@ -144,14 +138,8 @@ class UserController extends AbstractController
/** /**
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"}) * @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', userToDelete)") * @Security("is_granted('delete', userToDelete)")
*
* @param User $userToDelete
* @param Request $request
* @param TimesheetRepository $repository
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/ */
public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository) public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository): Response
{ {
// $userToDelete MUST not be called $user, as $user is always the current user! // $userToDelete MUST not be called $user, as $user is always the current user!
$stats = $repository->getUserStatistics($userToDelete); $stats = $repository->getUserStatistics($userToDelete);
@@ -189,27 +177,7 @@ class UserController extends AbstractController
); );
} }
/** protected function getToolbarForm(UserQuery $query): FormInterface
* @Route(path="/permissions", name="admin_user_permissions", methods={"GET", "POST"})
* @Security("is_granted('role_permissions')")
*
* @param RolePermissionManager $manager
* @return \Symfony\Component\HttpFoundation\Response
*/
public function permissions(RolePermissionManager $manager)
{
return $this->render('user/permissions.html.twig', [
'roles' => $manager->getRoles(),
'permissions' => $manager->getPermissions(),
'manager' => $manager,
]);
}
/**
* @param UserQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(UserQuery $query)
{ {
return $this->createForm(UserToolbarForm::class, $query, [ return $this->createForm(UserToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_user', [ 'action' => $this->generateUrl('admin_user', [
@@ -219,11 +187,7 @@ class UserController extends AbstractController
]); ]);
} }
/** private function createEditForm(User $user): FormInterface
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(User $user)
{ {
return $this->createForm(UserCreateType::class, $user, [ return $this->createForm(UserCreateType::class, $user, [
'action' => $this->generateUrl('admin_user_create'), 'action' => $this->generateUrl('admin_user_create'),

View File

@@ -525,7 +525,6 @@ class Configuration implements ConfigurationInterface
->requiresAtLeastOneElement() ->requiresAtLeastOneElement()
->useAttributeAsKey('key') ->useAttributeAsKey('key')
->arrayPrototype() ->arrayPrototype()
->useAttributeAsKey('key')
->isRequired() ->isRequired()
->scalarPrototype()->end() ->scalarPrototype()->end()
->defaultValue([]) ->defaultValue([])

59
src/Entity/Role.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_roles",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="roles_name", columns={"name"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\RoleRepository")
* @UniqueEntity("name")
*/
class Role
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=50, nullable=false)
* @Assert\Length(min=5, max=50)
*/
private $name;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): Role
{
$this->name = $name;
return $this;
}
}

View File

@@ -0,0 +1,101 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_roles_permissions",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="role_permission", columns={"role_id","permission"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\RolePermissionRepository")
* @UniqueEntity({"role", "permission"})
*/
class RolePermission
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var Role
*
* @ORM\ManyToOne(targetEntity="App\Entity\Role")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $role;
/**
* @var string
*
* @ORM\Column(name="permission", type="string", length=50, nullable=false)
* @Assert\Length(max=50)
*/
private $permission;
/**
* @var bool
*
* @ORM\Column(name="allowed", type="boolean", nullable=false, options={"default": false})
* @Assert\NotNull()
*/
private $allowed = false;
public function getId(): ?int
{
return $this->id;
}
public function getRole(): ?Role
{
return $this->role;
}
public function setRole(Role $role): RolePermission
{
$this->role = $role;
return $this;
}
public function getPermission(): ?string
{
return $this->permission;
}
public function setPermission(string $permission): RolePermission
{
$this->permission = $permission;
return $this;
}
/**
* Alias for isValue()
*/
public function isAllowed(): bool
{
return $this->allowed;
}
public function setAllowed(bool $allowed): RolePermission
{
$this->allowed = $allowed;
return $this;
}
}

View File

@@ -82,10 +82,6 @@ class DashboardSubscriber implements EventSubscriberInterface
*/ */
public function onDashboardEvent(DashboardEvent $event) public function onDashboardEvent(DashboardEvent $event)
{ {
if (!$this->security->isGranted(User::ROLE_ADMIN)) {
return;
}
$section = new CompoundRow(); $section = new CompoundRow();
$section->setTitle('ROLE_ADMIN'); $section->setTitle('ROLE_ADMIN');
$section->setOrder(100); $section->setOrder(100);

79
src/Form/RoleType.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Role;
use FOS\RestBundle\Validator\Constraints\Regex;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The form used to edit roles.
*/
class RoleType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'label.name',
'help' => 'Allowed character: A-Z and _',
'constraints' => [
new Regex(['pattern' => '/^[a-zA-Z_]{5,}$/'])
],
'attr' => [
'maxlength' => 50
]
])
;
// help the user to figure out the allowed name
$builder->get('name')->addViewTransformer(
new CallbackTransformer(
function ($roleName) {
if (is_string($roleName)) {
$roleName = str_replace(' ', '_', $roleName);
$roleName = str_replace('-', '_', $roleName);
$roleName = strtoupper($roleName);
}
return $roleName;
},
function ($roleName) {
return $roleName;
}
)
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Role::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_role',
'attr' => [
'data-form-event' => 'kimai.userRoleUpdate',
'data-msg-success' => 'action.update.success',
'data-msg-error' => 'action.update.error',
],
]);
}
}

View File

@@ -44,7 +44,7 @@ class UserRoleType extends AbstractType
$resolver->setDefault('choices', function (Options $options) { $resolver->setDefault('choices', function (Options $options) {
$roles = []; $roles = [];
foreach ($this->roles->getAvailableNames() as $name) { foreach ($this->roles->getAvailableNames() as $name) {
$roles[$name] = $name; $roles[$name] = strtoupper($name);
} }
if ($options['include_default'] !== true && isset($roles[User::DEFAULT_ROLE])) { if ($options['include_default'] !== true && isset($roles[User::DEFAULT_ROLE])) {

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds the user roles and role permissions table
*
* @version 1.6
*/
final class Version20191108151534 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the user roles and role permissions table';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$roles = $schema->createTable('kimai2_roles');
$roles->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$roles->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$roles->setPrimaryKey(['id']);
$roles->addUniqueIndex(['name'], 'roles_name');
$rolePermissions = $schema->createTable('kimai2_roles_permissions');
$rolePermissions->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$rolePermissions->addColumn('role_id', 'integer', ['length' => 11, 'notnull' => true]);
$rolePermissions->addColumn('permission', 'string', ['notnull' => true, 'length' => 50]);
$rolePermissions->addColumn('allowed', 'boolean', ['notnull' => true, 'default' => false]);
$rolePermissions->setPrimaryKey(['id']);
$rolePermissions->addUniqueIndex(['role_id', 'permission'], 'role_permission');
$rolePermissions->addForeignKeyConstraint('kimai2_roles', ['role_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_D263A3B8D60322AC');
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_roles_permissions');
$schema->dropTable('kimai2_roles');
}
}

View File

@@ -14,27 +14,53 @@ use App\Entity\Configuration;
use App\Form\Model\SystemConfiguration; use App\Form\Model\SystemConfiguration;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface
{ {
private static $cacheByPrefix = null;
private static $cacheAll = [];
private function clearCache()
{
static::$cacheByPrefix = null;
}
private function prefillCache()
{
if (null !== static::$cacheByPrefix) {
return;
}
/** @var Configuration[] $configs */
$configs = $this->findAll();
static::$cacheByPrefix = [];
foreach ($configs as $config) {
$key = substr($config->getName(), 0, strpos($config->getName(), '.'));
if (!array_key_exists($key, static::$cacheByPrefix)) {
static::$cacheByPrefix[$key] = [];
}
static::$cacheByPrefix[$key][] = $config;
static::$cacheAll[] = $config;
}
}
/** /**
* @param string $prefix * @param string $prefix
* @return Configuration[] * @return Configuration[]
*/ */
public function getConfiguration(?string $prefix = null): array public function getConfiguration(?string $prefix = null): array
{ {
$this->prefillCache();
if (null === $prefix) { if (null === $prefix) {
return $this->findAll(); return static::$cacheAll;
} }
$qb = $this->createQueryBuilder('c'); if (!array_key_exists($prefix, static::$cacheByPrefix)) {
$qb return [];
->select('c') }
->where($qb->expr()->like('c.name', ':prefix'))
->setParameter(':prefix', $prefix . '%');
return $qb->getQuery()->getResult(Query::HYDRATE_OBJECT); return static::$cacheByPrefix[$prefix];
} }
public function saveSystemConfiguration(SystemConfiguration $model) public function saveSystemConfiguration(SystemConfiguration $model)
@@ -68,5 +94,7 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
$em->rollback(); $em->rollback();
throw $ex; throw $ex;
} }
$this->clearCache();
} }
} }

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Role;
use App\Entity\RolePermission;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository;
class RolePermissionRepository extends EntityRepository
{
public function saveRolePermission(RolePermission $permission)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($permission);
$entityManager->flush();
}
public function findRolePermission(Role $role, string $permission)
{
return $this->findOneBy(['role' => $role, 'permission' => $permission]);
}
public function getAllAsArray()
{
$qb = $this->createQueryBuilder('rp');
$qb->select('r.name as role,rp.permission,rp.allowed')
->leftJoin('rp.role', 'r');
return $qb->getQuery()->execute([], AbstractQuery::HYDRATE_ARRAY);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Role;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
class RoleRepository extends EntityRepository
{
/**
* @return Role[]
*/
public function findAll()
{
return parent::findAll();
}
public function saveRole(Role $role)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($role);
$entityManager->flush();
}
public function deleteRole(Role $role)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($role);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -9,32 +9,61 @@
namespace App\Security; namespace App\Security;
class RolePermissionManager use App\Entity\User;
use App\Repository\RolePermissionRepository;
final class RolePermissionManager
{ {
/** /**
* @var array * @var array
*/ */
protected $permissions = []; private $permissions = [];
/** /**
* @var string[] * @var string[]
*/ */
protected $knownPermissions = []; private $knownPermissions = [];
/**
* @var RoleService
*/
private $roles;
public function __construct(RoleService $roles, array $permissions) public function __construct(RolePermissionRepository $repository, array $permissions)
{ {
$this->roles = $roles;
$this->permissions = $permissions; $this->permissions = $permissions;
foreach ($permissions as $role => $perms) { foreach ($permissions as $role => $perms) {
$this->knownPermissions = array_merge($this->knownPermissions, $perms); $this->knownPermissions = array_merge($this->knownPermissions, $perms);
} }
$this->knownPermissions = array_unique($this->knownPermissions); $this->knownPermissions = array_unique($this->knownPermissions);
$all = $repository->getAllAsArray();
foreach ($all as $item) {
$perm = $item['permission'];
$role = strtoupper($item['role']);
$isAllowed = $item['allowed'];
// see permissions.html.twig for this special case
if ($role === User::ROLE_SUPER_ADMIN && in_array($perm, ['role_permissions', 'view_user'])) {
continue;
}
if (!$isAllowed) {
if (array_key_exists($role, $this->permissions)) {
if (($key = array_search($perm, $this->permissions[$role])) !== false) {
unset($this->permissions[$role][$key]);
}
}
} else {
if (!array_key_exists($role, $this->permissions)) {
$this->permissions[$role] = [];
}
$this->permissions[$role][] = $perm;
}
}
} }
/**
* Only permissions which were registered through the Symfony configuration stack will be acknowledged here.
*
* @param string $permission
* @return bool
*/
public function isRegisteredPermission(string $permission): bool public function isRegisteredPermission(string $permission): bool
{ {
return in_array($permission, $this->knownPermissions); return in_array($permission, $this->knownPermissions);
@@ -42,6 +71,8 @@ class RolePermissionManager
public function hasPermission(string $role, string $permission): bool public function hasPermission(string $role, string $permission): bool
{ {
$role = strtoupper($role);
if (!isset($this->permissions[$role])) { if (!isset($this->permissions[$role])) {
return false; return false;
} }
@@ -49,11 +80,11 @@ class RolePermissionManager
return in_array($permission, $this->permissions[$role]); return in_array($permission, $this->permissions[$role]);
} }
public function getRoles(): array /**
{ * Only permissions which were registered through the Symfony configuration stack will be returned here.
return $this->roles->getAvailableNames(); *
} * @return array
*/
public function getPermissions(): array public function getPermissions(): array
{ {
return $this->knownPermissions; return $this->knownPermissions;

View File

@@ -9,6 +9,9 @@
namespace App\Security; namespace App\Security;
use App\Entity\Role;
use App\Repository\RoleRepository;
final class RoleService final class RoleService
{ {
/** /**
@@ -19,13 +22,18 @@ final class RoleService
* @var string[] * @var string[]
*/ */
private $roleNames = []; private $roleNames = [];
/**
* @var RoleRepository
*/
private $repository;
public function __construct(array $roles) public function __construct(RoleRepository $repository, array $roles)
{ {
$this->repository = $repository;
$this->roles = $roles; $this->roles = $roles;
} }
public function getAvailableNames(): array private function cacheNames()
{ {
if (empty($this->roleNames)) { if (empty($this->roleNames)) {
$roles = []; $roles = [];
@@ -38,9 +46,24 @@ final class RoleService
} }
} }
/** @var Role $item */
foreach ($this->repository->findAll() as $item) {
$roles[] = $item->getName();
}
$this->roleNames = array_values(array_unique($roles)); $this->roleNames = array_values(array_unique($roles));
} }
}
public function getAvailableNames(): array
{
$this->cacheNames();
return $this->roleNames; return $this->roleNames;
} }
public function getSystemRoles(): array
{
return $this->roles;
}
} }

View File

@@ -62,6 +62,7 @@ final class IconExtension extends AbstractExtension
'profile-stats' => 'far fa-chart-bar', 'profile-stats' => 'far fa-chart-bar',
'project' => 'fas fa-briefcase', 'project' => 'fas fa-briefcase',
'repeat' => 'fas fa-redo-alt', 'repeat' => 'fas fa-redo-alt',
'roles' => 'fas fa-user-shield',
'search' => 'fas fa-search', 'search' => 'fas fa-search',
'settings' => 'fas fa-cog', 'settings' => 'fas fa-cog',
'shop' => 'fas fa-shopping-cart', 'shop' => 'fas fa-shopping-cart',

View File

@@ -41,7 +41,8 @@ class RoleValidator extends ConstraintValidator
$roles = [$roles]; $roles = [$roles];
} }
$allowedRoles = $this->service->getAvailableNames(); // the fos user entity uppercases the roles by default
$allowedRoles = array_map('strtoupper', $this->service->getAvailableNames());
foreach ($roles as $role) { foreach ($roles as $role) {
if (!is_string($role) || !in_array($role, $allowedRoles)) { if (!is_string($role) || !in_array($role, $allowedRoles)) {

View File

@@ -29,10 +29,6 @@ abstract class AbstractVoter extends Voter
*/ */
protected $roleManager; protected $roleManager;
/**
* @param AclDecisionManager $decisionManager
* @param RolePermissionManager $roleManager
*/
public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager) public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager)
{ {
$this->decisionManager = $decisionManager; $this->decisionManager = $decisionManager;

View File

@@ -30,13 +30,7 @@ class RolePermissionVoter extends AbstractVoter
return false; return false;
} }
// and which is not neither a user role like USER_ADMIN return $this->isRegisteredPermission($attribute);
// nor an implicit role like IS_REMEMBERED / IS_FULLY_AUTHENTICATED
if (strpos($attribute, 'ROLE_') === false && strpos($attribute, 'IS_') === false) {
return $this->isRegisteredPermission($attribute);
}
return false;
} }
/** /**
@@ -53,12 +47,6 @@ class RolePermissionVoter extends AbstractVoter
return false; return false;
} }
foreach ($user->getRoles() as $role) { return $this->hasRolePermission($user, $attribute);
if ($this->hasPermission($role, $attribute)) {
return true;
}
}
return false;
} }
} }

View File

@@ -82,15 +82,8 @@ class UserVoter extends AbstractVoter
return $this->hasRolePermission($user, 'delete_user'); return $this->hasRolePermission($user, 'delete_user');
// used in templates and ProfileController
case self::VIEW: case self::VIEW:
case self::EDIT: case self::EDIT:
// always allow the user to edit these own settings
if ($subject->getId() === $user->getId()) {
return true;
}
// no break on purpose
case self::PREFERENCES: case self::PREFERENCES:
case self::PASSWORD: case self::PASSWORD:
case self::API_TOKEN: case self::API_TOKEN:

View File

@@ -93,7 +93,7 @@
</li> </li>
{% endif %} {% endif %}
{% block navbar_extensions %}{% endblock %} {% block navbar_extensions %}{% endblock %}
{{ render(controller('App\\Controller\\TimesheetController::activeEntriesAction')) }} {{ render(controller('App\\Controller\\LayoutController::activeEntries')) }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -18,8 +18,12 @@
{% block main %} {% block main %}
{% for row in widgets %} {% if widgets is empty %}
{{ render_widget(row) }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endfor %} {% else %}
{% for row in widgets %}
{{ render_widget(row) }}
{% endfor %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -39,7 +39,13 @@
{% endmacro %} {% endmacro %}
{% macro datatable_header(tableName, columns, query, options) %} {% macro datatable_header(tableName, columns, query, options) %}
{% set orderBy = options.orderBy|default(query.orderBy) %} {% if query is not null %}
{% set orderBy = options.orderBy|default(query.orderBy) %}
{% set order = query.order|lower %}
{% else %}
{% set orderBy = false %}
{% set order = false %}
{% endif %}
{% set striped = options.striped|default(true) %} {% set striped = options.striped|default(true) %}
{% set reloadEvent = options.reload|default('') %} {% set reloadEvent = options.reload|default('') %}
{% set translationDomain = options.translationDomain|default('messages') %} {% set translationDomain = options.translationDomain|default('messages') %}
@@ -63,12 +69,16 @@
{% set headerOptions = {'class': headerOptions} %} {% set headerOptions = {'class': headerOptions} %}
{% endif %} {% endif %}
{% if not headerOptions.orderBy is defined %} {% if not headerOptions.orderBy is defined %}
{% set headerOptions = headerOptions|merge({'orderBy': title}) %} {% if orderBy is same as(false) %}
{% set headerOptions = headerOptions|merge({'orderBy': false}) %}
{% else %}
{% set headerOptions = headerOptions|merge({'orderBy': title}) %}
{% endif %}
{% endif %} {% endif %}
{% set headerClass = macro.data_table_column_class(tableName, columns, title) %} {% set headerClass = macro.data_table_column_class(tableName, columns, title) %}
{% if title != 'actions' and not headerOptions.orderBy is same as(false) %} {% if title != 'actions' and not headerOptions.orderBy is same as(false) %}
{% if orderBy == headerOptions.orderBy %} {% if orderBy == headerOptions.orderBy %}
{% set headerClass = headerClass ~ ' sortable sorting_' ~ (query.order|lower) %} {% set headerClass = headerClass ~ ' sortable sorting_' ~ (order) %}
{% else %} {% else %}
{% set headerClass = headerClass ~ ' sortable sorting' %} {% set headerClass = headerClass ~ ' sortable sorting' %}
{% endif %} {% endif %}
@@ -79,7 +89,15 @@
{% elseif title is not empty and title != 'actions' %} {% elseif title is not empty and title != 'actions' %}
{% set headerTitle = (translationPrefix ~ title)|trans({}, translationDomain) %} {% set headerTitle = (translationPrefix ~ title)|trans({}, translationDomain) %}
{% endif %} {% endif %}
<th data-field="{{ title }}" {% if not headerOptions.orderBy is same as(false) %}data-order="{{ headerOptions.orderBy }}" {% endif %}class="{{ headerClass }}">{{ headerTitle }}</th> <th data-field="{{ title }}" {% if not headerOptions.orderBy is same as(false) %}data-order="{{ headerOptions.orderBy }}" {% endif %}class="{{ headerClass }}">
{% if headerOptions.html_before is defined %}
{{ headerOptions.html_before|raw }}
{% endif %}
{{ headerTitle }}
{% if headerOptions.html_after is defined %}
{{ headerOptions.html_after|raw }}
{% endif %}
</th>
{%- endfor -%} {%- endfor -%}
</tr> </tr>
</thead> </thead>

View File

@@ -9,7 +9,7 @@
{% set actions = actions|merge({'back': path('admin_user')}) %} {% set actions = actions|merge({'back': path('admin_user')}) %}
{% endif %} {% endif %}
{% if view != 'permissions' and is_granted('role_permissions') %} {% if is_granted('role_permissions') %}
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %} {% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
{% endif %} {% endif %}
@@ -33,7 +33,18 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %} {% set actions = {} %}
{% set actions = actions|merge({'back': path('admin_user')}) %} {% if is_granted('view_user') %}
{% set actions = actions|merge({'back': path('admin_user')}) %}
{% endif %}
{% if view != 'index' and is_granted('role_permissions') %}
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
{% endif %}
{% if view != 'role' and is_granted('role_permissions') %}
{% set actions = actions|merge({'roles': {'url': path('admin_user_roles'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %} {% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.user_permissions', {'actions': actions, 'view': view}) %} {% set event = trigger('actions.user_permissions', {'actions': actions, 'view': view}) %}

View File

@@ -0,0 +1,14 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "user/actions.html.twig" as actions %}
{% block page_title %}{{ 'user_role.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.user_permissions('role') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': (role.id ? 'action.edit'|trans : 'create'|trans) ~ ': ' ~ 'user_role.title'|trans,
'form': form,
'back': path('admin_user_permissions')
}) }}
{% endblock %}

View File

@@ -7,28 +7,40 @@
{% set columns = { {% set columns = {
'label.name': 'alwaysVisible', 'label.name': 'alwaysVisible',
} %} } %}
{% set canEditPedrmissions = is_granted('role_permissions') %}
{% for role in roles %} {% for role in roles %}
{% set options = {'class': 'alwaysVisible text-center'} %}
{% if canEditPedrmissions and role.name not in system_roles|keys %}
{% set widget %}
&nbsp;<a href="{{ path('admin_user_role_delete', {'id': role.id}) }}" class="confirmation-link" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ widgets.icon('trash') }}</a>
{% endset %}
{% set options = options|merge({'html_after': widget}) %}
{% endif %}
{% set columns = columns|merge({ {% set columns = columns|merge({
(role): 'alwaysVisible text-center', (role.name|trans): options,
}) %} }) %}
{% endfor %} {% endfor %}
{% set tableName = 'user_admin_permissions' %} {% set tableName = 'user_admin_permissions' %}
{% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %} {% block page_title %}{{ 'user_permissions.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.user_permissions('index') }}{% endblock %} {% block page_actions %}{{ actions.user_permissions('index') }}{% endblock %}
{% block main %} {% block main %}
{{ tables.datatable_header(tableName, columns, null, {'translationPrefix': ''}) }}
{{ tables.data_table_header_options(tableName, columns, {'translationPrefix': ''}) }}
{% for permission in permissions|sort %} {% for permission in permissions|sort %}
<tr> <tr>
<td>{{ permission }}</td> <td>{{ permission }}</td>
{% for role in roles %} {% for role in roles %}
{% set value = manager.permission(role.name, permission) %}
<td class="text-center"> <td class="text-center">
{{ widgets.label_boolean(manager.permission(role, permission)) }} {# see RolePermissionManager for this special case #}
{% if (permission != 'role_permissions' and permission != 'view_user') or role.name != 'ROLE_SUPER_ADMIN' %}
<a href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': (value ? '0' : '1')}) }}">{{ widgets.label_boolean(value) }}</a>
{% else %}
{{ widgets.label_boolean(value) }}
{% endif %}
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -37,3 +49,12 @@
{{ tables.data_table_footer(permissions) }} {{ tables.data_table_footer(permissions) }}
{% endblock %} {% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.userRoleUpdate', function() {
document.location.reload();
});
</script>
{% endblock %}

View File

@@ -15,7 +15,7 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
/** /**
* @group integration * @group integration
*/ */
class LayoutTest extends ControllerBaseTest class LayoutControllerTest extends ControllerBaseTest
{ {
public function testNavigationMenus() public function testNavigationMenus()
{ {
@@ -72,4 +72,26 @@ class LayoutTest extends ControllerBaseTest
$this->assertStringContainsString('<a href="/en/calendar/">', $content); $this->assertStringContainsString('<a href="/en/calendar/">', $content);
$this->assertStringContainsString('<span>Calendar</span>', $content); $this->assertStringContainsString('<span>Calendar</span>', $content);
} }
public function testActiveEntries()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_USER);
$this->request($client, '/layou/active_entries');
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertStringContainsString('<li class="dropdown messages-menu" style="display:none">', $content);
self::assertStringContainsString('<ul class="dropdown-menu"', $content);
self::assertStringContainsString('data-api="', $content);
self::assertStringContainsString('data-href="', $content);
self::assertStringContainsString('data-icon=', $content);
self::assertStringContainsString('data-format=', $content);
self::assertStringContainsString('<ul class="menu">', $content);
self::assertStringContainsString('<li class="messages-menu-empty" style="">', $content);
}
} }

View File

@@ -0,0 +1,158 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Controller;
use App\Entity\RolePermission;
use App\Entity\User;
use Doctrine\ORM\EntityManager;
/**
* @group integration
*/
class PermissionControllerTest extends ControllerBaseTest
{
public function testPermissionsIsSecure()
{
$this->assertUrlIsSecured('/admin/permissions');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
}
public function testPermissions()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
'help' => 'https://www.kimai.org/documentation/permissions.html'
]);
$content = $client->getResponse()->getContent();
// the english translation instead of the real system user role names
self::assertStringContainsString('<th data-field="User" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="Teamlead" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="Administrator" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="System-Admin" class="alwaysVisible text-center">', $content);
}
public function testCreateRoleIsSecured()
{
$this->assertUrlIsSecured('/admin/permissions/roles/create');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
}
public function testCreateRole()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
$form = $client->getCrawler()->filter('form[name=role]')->form();
$client->submit($form, [
'role' => [
'name' => 'TEST_ROLE',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
$client->followRedirect();
$content = $client->getResponse()->getContent();
// the english translation instead of the real system user role names
self::assertStringContainsString('<th data-field="User" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="Teamlead" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="Administrator" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="System-Admin" class="alwaysVisible text-center">', $content);
self::assertStringContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
}
public function testDeleteRoleIsSecured()
{
$this->assertUrlIsSecured('/admin/permissions/roles/1/delete');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
}
public function testDeleteRole()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
$form = $client->getCrawler()->filter('form[name=role]')->form();
$client->submit($form, [
'role' => [
'name' => 'TEST_ROLE',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
$client->followRedirect();
$content = $client->getResponse()->getContent();
self::assertStringContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
$this->request($client, '/admin/permissions/roles/1/delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
$client->followRedirect();
self::assertHasFlashDeleteSuccess($client);
$content = $client->getResponse()->getContent();
self::assertStringNotContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
}
public function testSavePermissionIsSecured()
{
$this->assertUrlIsSecured('/admin/permissions/roles/1/view_user/1');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
}
public function testSavePermission()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
$form = $client->getCrawler()->filter('form[name=role]')->form();
$client->submit($form, [
'role' => [
'name' => 'TEST_ROLE',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
$this->assertEquals(0, count($rolePermissions));
// create the permission
$this->request($client, '/admin/permissions/roles/1/view_user/1');
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
$client->followRedirect();
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
$this->assertEquals(1, count($rolePermissions));
$permission = $rolePermissions[0];
self::assertInstanceOf(RolePermission::class, $permission);
self::assertEquals('view_user', $permission->getPermission());
self::assertTrue($permission->isAllowed());
self::assertEquals('TEST_ROLE', $permission->getRole()->getName());
self::assertEquals(1, $permission->getRole()->getId());
// flush the cache to prevent wrong results
$em->clear(RolePermission::class);
// update the permission
$this->request($client, '/admin/permissions/roles/1/view_user/0');
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
$client->followRedirect();
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
$this->assertEquals(1, count($rolePermissions));
$permission = $rolePermissions[0];
self::assertInstanceOf(RolePermission::class, $permission);
self::assertEquals('view_user', $permission->getPermission());
self::assertFalse($permission->isAllowed());
}
}

View File

@@ -30,6 +30,13 @@ class UserControllerTest extends ControllerBaseTest
$this->assertAccessIsGranted($client, '/admin/user/'); $this->assertAccessIsGranted($client, '/admin/user/');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin', 7); $this->assertDataTableRowCount($client, 'datatable_user_admin', 7);
$this->assertPageActions($client, [
'search search-toggle visible-xs-inline' => '#',
'visibility' => '#',
'permissions' => $this->createUrl('/admin/permissions'),
'create' => $this->createUrl('/admin/user/create'),
'help' => 'https://www.kimai.org/documentation/users.html'
]);
} }
public function testIndexActionWithSearchTermQuery() public function testIndexActionWithSearchTermQuery()
@@ -220,18 +227,4 @@ class UserControllerTest extends ControllerBaseTest
], ],
]; ];
} }
public function testPermissionsIsSecure()
{
$this->assertUrlIsSecured('/admin/user/permissions');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/user/permissions');
}
public function testPermissions()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83);
}
} }

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Entity;
use App\Entity\Role;
use App\Entity\RolePermission;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\RolePermission
*/
class RolePermissionTest extends TestCase
{
public function testDefaultValues()
{
$sut = new RolePermission();
self::assertNull($sut->getId());
self::assertNull($sut->getPermission());
self::assertNull($sut->getRole());
self::assertFalse($sut->isAllowed());
}
public function testSetterAndGetter()
{
$sut = new RolePermission();
self::assertInstanceOf(RolePermission::class, $sut->setPermission('foo'));
self::assertEquals('foo', $sut->getPermission());
$role = (new Role())->setName('sdfsd');
self::assertInstanceOf(RolePermission::class, $sut->setRole($role));
self::assertSame($role, $sut->getRole());
self::assertInstanceOf(RolePermission::class, $sut->setAllowed(true));
self::assertTrue($sut->isAllowed());
}
}

34
tests/Entity/RoleTest.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Entity;
use App\Entity\Role;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\Role
*/
class RoleTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Role();
self::assertNull($sut->getId());
self::assertNull($sut->getName());
}
public function testSetterAndGetter()
{
$sut = new Role();
self::assertInstanceOf(Role::class, $sut->setName('foo'));
self::assertEquals('foo', $sut->getName());
}
}

View File

@@ -15,7 +15,7 @@ use App\Ldap\LdapDriver;
use App\Ldap\LdapDriverException; use App\Ldap\LdapDriverException;
use App\Ldap\LdapManager; use App\Ldap\LdapManager;
use App\Ldap\LdapUserHydrator; use App\Ldap\LdapUserHydrator;
use App\Security\RoleService; use App\Tests\Mocks\Security\RoleServiceFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -50,11 +50,13 @@ class LdapManagerTest extends TestCase
'role' => $roleConfig, 'role' => $roleConfig,
]); ]);
$hydrator = new LdapUserHydrator($config, new RoleService([ $roles = [
'ROLE_TEAMLEAD' => ['ROLE_USER'], 'ROLE_TEAMLEAD' => ['ROLE_USER'],
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'], 'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN'] 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
])); ];
$hydrator = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create($roles));
return new LdapManager($driver, $hydrator, $config); return new LdapManager($driver, $hydrator, $config);
} }

View File

@@ -12,7 +12,7 @@ namespace App\Tests\Ldap;
use App\Configuration\LdapConfiguration; use App\Configuration\LdapConfiguration;
use App\Entity\User; use App\Entity\User;
use App\Ldap\LdapUserHydrator; use App\Ldap\LdapUserHydrator;
use App\Security\RoleService; use App\Tests\Mocks\Security\RoleServiceFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -33,7 +33,7 @@ class LdapUserHydratorTest extends TestCase
'role' => [], 'role' => [],
]); ]);
$sut = new LdapUserHydrator($config, new RoleService([])); $sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
$user = $sut->hydrate(['dn' => 'blub']); $user = $sut->hydrate(['dn' => 'blub']);
self::assertInstanceOf(User::class, $user); self::assertInstanceOf(User::class, $user);
self::assertEmpty($user->getUsername()); self::assertEmpty($user->getUsername());
@@ -71,7 +71,7 @@ class LdapUserHydratorTest extends TestCase
'dn' => 'blub', 'dn' => 'blub',
]; ];
$sut = new LdapUserHydrator($config, new RoleService([])); $sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
$user = $sut->hydrate($ldapEntry); $user = $sut->hydrate($ldapEntry);
self::assertInstanceOf(User::class, $user); self::assertInstanceOf(User::class, $user);
@@ -113,7 +113,7 @@ class LdapUserHydratorTest extends TestCase
'dn' => 'blub', 'dn' => 'blub',
]; ];
$sut = new LdapUserHydrator($config, new RoleService([])); $sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
$user = new User(); $user = new User();
$user->setPassword('foobar'); $user->setPassword('foobar');
$sut->hydrateUser($user, $ldapEntry); $sut->hydrateUser($user, $ldapEntry);
@@ -176,11 +176,13 @@ class LdapUserHydratorTest extends TestCase
'count' => 4 'count' => 4
]; ];
$sut = new LdapUserHydrator($config, new RoleService([ $roles = [
'ROLE_TEAMLEAD' => ['ROLE_USER'], 'ROLE_TEAMLEAD' => ['ROLE_USER'],
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'], 'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN'] 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
])); ];
$sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create($roles));
$user = new User(); $user = new User();
$sut->hydrateRoles($user, $ldapGroups); $sut->hydrateRoles($user, $ldapGroups);
self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN', 'ROLE_USER'], $user->getRoles());

View File

@@ -9,13 +9,20 @@
namespace App\Tests\Mocks\Security; namespace App\Tests\Mocks\Security;
use App\Entity\Role;
use App\Entity\User; use App\Entity\User;
use App\Repository\RoleRepository;
use App\Security\RoleService; use App\Security\RoleService;
use App\Tests\Mocks\AbstractMockFactory; use App\Tests\Mocks\AbstractMockFactory;
class RoleServiceFactory extends AbstractMockFactory class RoleServiceFactory extends AbstractMockFactory
{ {
public function create($roles = null): RoleService /**
* @param string[]|null $roles
* @param Role[]|null $repositoryRoles
* @return RoleService
*/
public function create(?array $roles = null, ?array $repositoryRoles = []): RoleService
{ {
if (null === $roles) { if (null === $roles) {
$roles = [ $roles = [
@@ -26,6 +33,10 @@ class RoleServiceFactory extends AbstractMockFactory
]; ];
} }
return new RoleService($roles); $repository = $this->getMockBuilder(RoleRepository::class)->onlyMethods(['findAll'])->disableOriginalConstructor()->getMock();
$repository->method('findAll')->willReturn($repositoryRoles);
/* @var RoleRepository $repository */
return new RoleService($repository, $roles);
} }
} }

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Security;
use App\Repository\RolePermissionRepository;
use App\Security\RolePermissionManager;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Security\RolePermissionManager
*/
class RolePermissionManagerTest extends TestCase
{
public function testWithEmptyRepository()
{
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$repository->method('getAllAsArray')->willReturn([]);
/** @var RolePermissionRepository $repository */
$sut = new RolePermissionManager($repository, []);
self::assertFalse($sut->isRegisteredPermission('foo'));
self::assertEquals([], $sut->getPermissions());
self::assertFalse($sut->hasPermission('TEST_ROLE', 'foo'));
}
public function testWithRepositoryData()
{
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$repository->method('getAllAsArray')->willReturn([
['permission' => 'foo', 'role' => 'TEST_ROLE', 'allowed' => true],
['permission' => 'bar', 'role' => 'USER_ROLE', 'allowed' => true],
['permission' => 'foo', 'role' => 'USER_ROLE', 'allowed' => false],
]);
/** @var RolePermissionRepository $repository */
$sut = new RolePermissionManager($repository, []);
// only data injected through the config will be registered as "known"
self::assertFalse($sut->isRegisteredPermission('foo'));
self::assertFalse($sut->isRegisteredPermission('bar'));
self::assertEquals([], $sut->getPermissions());
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo'));
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
}
public function testWithConfigData()
{
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$repository->method('getAllAsArray')->willReturn([]);
/** @var RolePermissionRepository $repository */
$sut = new RolePermissionManager($repository, ['TEST_ROLE' => ['foo'], 'USER_ROLE' => ['bar']]);
self::assertTrue($sut->isRegisteredPermission('foo'));
self::assertTrue($sut->isRegisteredPermission('bar'));
self::assertEquals(['foo', 'bar'], $sut->getPermissions());
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo'));
self::assertFalse($sut->hasPermission('TEST_ROLE', 'bar'));
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
}
public function testWithMixedData()
{
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$repository->method('getAllAsArray')->willReturn([
['permission' => 'foo', 'role' => 'TEST_ROLE', 'allowed' => false],
['permission' => 'bar', 'role' => 'USER_ROLE', 'allowed' => true],
['permission' => 'foo', 'role' => 'USER_ROLE', 'allowed' => false],
['permission' => 'role_permissions', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
['permission' => 'view_user', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
['permission' => 'create_user', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
]);
/** @var RolePermissionRepository $repository */
$sut = new RolePermissionManager($repository, [
'ROLE_SUPER_ADMIN' => ['role_permissions', 'view_user', 'create_user'],
'TEST_ROLE' => ['foo2', 'foo'],
'USER_ROLE' => ['foo', 'bar']
]);
self::assertTrue($sut->isRegisteredPermission('foo'));
self::assertTrue($sut->isRegisteredPermission('bar'));
self::assertEquals(['role_permissions', 'view_user', 'create_user', 'foo2', 'foo', 'bar'], array_values($sut->getPermissions()));
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo2'));
self::assertFalse($sut->hasPermission('TEST_ROLE', 'foo'));
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
self::assertFalse($sut->hasPermission('ROLE_SUPER_ADMIN', 'create_user'));
// the next two are a special case, which might never be falsified by the database
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'role_permissions'));
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'view_user'));
}
}

View File

@@ -9,7 +9,8 @@
namespace App\Tests\Security; namespace App\Tests\Security;
use App\Security\RoleService; use App\Entity\Role;
use App\Tests\Mocks\Security\RoleServiceFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -17,7 +18,7 @@ use PHPUnit\Framework\TestCase;
*/ */
class RoleServiceTest extends TestCase class RoleServiceTest extends TestCase
{ {
public function testGetAvailableNames() public function testWithEmptyRepository()
{ {
$real = [ $real = [
'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'], 'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'],
@@ -25,10 +26,34 @@ class RoleServiceTest extends TestCase
'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN'] 'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN']
]; ];
$sut = new RoleService($real); $sut = (new RoleServiceFactory($this))->create($real);
$expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN']; $expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN'];
self::assertEquals($expected, $sut->getAvailableNames()); self::assertEquals($expected, $sut->getAvailableNames());
self::assertEquals($real, $sut->getSystemRoles());
}
public function testWithRepositoryData()
{
$real = [
'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'],
'ROLE_ADMIN' => [0 => 'ROLE_TEAMLEAD'],
'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN']
];
$repository = [
(new Role())->setName('TEST_ROLE'),
(new Role())->setName('ROLE_ADMIN'),
(new Role())->setName('ROLE_ADMINX'),
(new Role())->setName('TEST_ROLE'),
];
$sut = (new RoleServiceFactory($this))->create($real, $repository);
$expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN', 'TEST_ROLE', 'ROLE_ADMINX'];
self::assertEquals($expected, $sut->getAvailableNames());
self::assertEquals($real, $sut->getSystemRoles());
} }
} }

View File

@@ -10,9 +10,9 @@
namespace App\Tests\Voter; namespace App\Tests\Voter;
use App\Entity\User; use App\Entity\User;
use App\Repository\RolePermissionRepository;
use App\Security\AclDecisionManager; use App\Security\AclDecisionManager;
use App\Security\RolePermissionManager; use App\Security\RolePermissionManager;
use App\Tests\Mocks\Security\RoleServiceFactory;
use App\Voter\AbstractVoter; use App\Voter\AbstractVoter;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -97,9 +97,10 @@ abstract class AbstractVoterTest extends TestCase
]; ];
} }
$factory = new RoleServiceFactory($this); $repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
$roleService = $factory->create(); $repository->method('getAllAsArray')->willReturn([]);
return new RolePermissionManager($roleService, $permissions); /* @var RolePermissionRepository $repository */
return new RolePermissionManager($repository, $permissions);
} }
} }

View File

@@ -616,7 +616,19 @@
Momentan existieren für den Benutzer %user% insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen. Momentan existieren für den Benutzer %user% insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.
</target> </target>
</trans-unit> </trans-unit>
<trans-unit id="user_permissions.title">
<source>user_permissions.title</source>
<target>Benutzer Berechtigungen</target>
</trans-unit>
<trans-unit id="user_role.title">
<source>user_role.title</source>
<target>Benutzer Rolle</target>
</trans-unit>
<trans-unit id="Allowed character: A-Z and _">
<source>Allowed character: A-Z and _</source>
<target>Erlaubte Zeichen: A-Z und _</target>
</trans-unit>
<!-- <!--
Admin: Plugins Admin: Plugins
--> -->

View File

@@ -616,6 +616,18 @@
Currently the user %user% has %records% time-records which sum up to a total of %duration%. Currently the user %user% has %records% time-records which sum up to a total of %duration%.
</target> </target>
</trans-unit> </trans-unit>
<trans-unit id="user_permissions.title">
<source>user_permissions.title</source>
<target>User permissions</target>
</trans-unit>
<trans-unit id="user_role.title">
<source>user_role.title</source>
<target>User role</target>
</trans-unit>
<trans-unit id="Allowed character: A-Z and _">
<source>Allowed character: A-Z and _</source>
<target>Allowed character: A-Z and _</target>
</trans-unit>
<!-- <!--
Admin: Plugins Admin: Plugins

Binary file not shown.