diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index df256f50..3145037a 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -1,10 +1,12 @@ kimai: + # -------------------------------------------------------------------------------- # Settings for the user management and login forms user: registration: true password_reset: true + # -------------------------------------------------------------------------------- # All configs related to timesheet and record management timesheet: @@ -33,6 +35,7 @@ kimai: # days: ['saturday','sunday'] # factor: 1.5 + # -------------------------------------------------------------------------------- # Invoice management #invoice: # # all files in these directories will be used as invoice documents (if supported by a renderer) @@ -40,6 +43,45 @@ kimai: # - 'var/invoices/' # - 'templates/invoice/renderer/' + # -------------------------------------------------------------------------------- + # Default settings used to populate forms + #defaults: + # customer: + # timezone: Europe/Berlin + # country: DE + # currency: EUR + + # -------------------------------------------------------------------------------- + # Free configurable permission system, see var/docs/permissions.md + permissions: + # mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"]) + sets: + ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity] + PROJECTS: [view_project,create_project,edit_project,delete_project] + CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer] + INVOICE: [view_invoice,create_invoice] + INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_invoice_template] + TIMESHEET: [view_own_timesheet,start_own_timesheet,stop_own_timesheet,create_own_timesheet,edit_own_timesheet,export_own_timesheet,delete_own_timesheet] + TIMESHEET_OTHER: [view_other_timesheet,start_other_timesheet,stop_other_timesheet,create_other_timesheet,edit_other_timesheet,delete_other_timesheet] + PROFILE: [view_own_profile,edit_own_profile,password_own_profile,preferences_own_profile,api-token_own_profile] + PROFILE_OTHER: [view_other_profile,edit_other_profile,delete_other_profile,password_other_profile,roles_other_profile,preferences_other_profile,api-token_other_profile] + USER: [view_user,create_user,delete_user] + RATE: [view_rate_own_timesheet,edit_rate_own_timesheet] + RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet] + # mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) + maps: + ROLE_USER: [TIMESHEET,PROFILE] + ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE] + ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER] + ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER] + # adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"]) + roles: + ROLE_USER: [] + ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile] + ROLE_ADMIN: [hourly-rate_own_profile] + ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile] + + # -------------------------------------------------------------------------------- # Language specific settings, like the date formats languages: de: @@ -75,6 +117,7 @@ kimai: duration: '%%h:%%m óra' duration_short: '%%h:%%m h' + # -------------------------------------------------------------------------------- # timesheet calendar configuration calendar: week_numbers: true @@ -93,6 +136,7 @@ kimai: # id: 'de.german#holiday@group.v.calendar.google.com' # color: '#ccc' + # -------------------------------------------------------------------------------- # theme related settings, will be available as twig globals at "kimai_context.*" # please see documentation at var/docs/theme.md theme: @@ -105,6 +149,7 @@ kimai: # possible options: blue, black, purple, yellow, red, green box_color: 'green' + # -------------------------------------------------------------------------------- # Dashboard widget sections, please see documentation at var/docs/dashboard.md dashboard: user_duration: @@ -133,6 +178,7 @@ kimai: permission: ROLE_ADMIN widgets: [amountToday, amountWeek, amountMonth, amountYear] + # -------------------------------------------------------------------------------- # All available widgets, please see documentation at var/docs/dashboard.md widgets: userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green } @@ -161,10 +207,3 @@ kimai: activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow } activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red } activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red } - - # Default settings used to populate forms - #defaults: - # customer: - # timezone: Europe/Berlin - # country: DE - # currency: EUR \ No newline at end of file diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a936332a..c5ee3f24 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -47,5 +47,5 @@ security: - { path: '^/(%app_locales%)/login', role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN } + - { path: '^/(%app_locales%)/', roles: ROLE_USER } - { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED } diff --git a/config/services.yaml b/config/services.yaml index 50d0c8ba..f5865be7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -118,6 +118,13 @@ services: App\Timesheet\Calculator\RateCalculator: arguments: ["%kimai.timesheet.rates%"] + # ================================================================================ + # SECURITY & VOTER + # ================================================================================ + + App\Security\RolePermissionManager: + arguments: ["%kimai.permissions%"] + # ================================================================================ # REPOSITORIES # ================================================================================ diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 0f710af4..f782e905 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -28,8 +28,6 @@ use Symfony\Component\HttpFoundation\Response; /** * @RouteResource("Timesheet") - * - * @Security("is_granted('ROLE_USER')") */ class TimesheetController extends BaseApiController { @@ -67,6 +65,8 @@ class TimesheetController extends BaseApiController * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'begin', 'end', 'rate')") * + * @Security("is_granted('view_own_timesheet')") + * * @return Response */ public function cgetAction(ParamFetcherInterface $paramFetcher) @@ -120,6 +120,8 @@ class TimesheetController extends BaseApiController * @SWG\Schema(ref="#/definitions/TimesheetEntity") * ) * + * @Security("is_granted('view_own_timesheet')") + * * @param int $id * @return Response */ @@ -146,6 +148,8 @@ class TimesheetController extends BaseApiController * ) * ) * + * @Security("is_granted('create_own_timesheet')") + * * @param Request $request * @return Response */ diff --git a/src/API/UserController.php b/src/API/UserController.php index c8342ee0..b3b93b36 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -22,9 +22,6 @@ use Symfony\Component\HttpFoundation\Response; /** * @RouteResource("User") - * - * @Security("is_granted('ROLE_SUPER_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") */ class UserController extends BaseApiController { @@ -55,6 +52,8 @@ class UserController extends BaseApiController * @SWG\Schema(ref="#/definitions/UserCollection"), * ) * + * @Security("is_granted('view_user')") + * * @return Response */ public function cgetAction() @@ -73,6 +72,8 @@ class UserController extends BaseApiController * @SWG\Schema(ref="#/definitions/UserEntity"), * ) * + * @Security("is_granted('view_user')") + * * @param int $id * @return Response */ diff --git a/src/Controller/Admin/ActivityController.php b/src/Controller/Admin/ActivityController.php index d21c1323..1fd40a57 100644 --- a/src/Controller/Admin/ActivityController.php +++ b/src/Controller/Admin/ActivityController.php @@ -27,8 +27,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage activities in the admin part of the site. * * @Route(path="/admin/activity") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_activity')") */ class ActivityController extends AbstractController { @@ -44,6 +43,11 @@ class ActivityController extends AbstractController * @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"}) * @Cache(smaxage="10") + * @Security("is_granted('view_activity')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -71,6 +75,7 @@ class ActivityController extends AbstractController /** * @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"}) + * @Security("is_granted('create_activity')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -94,8 +99,6 @@ class ActivityController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', activity)") * diff --git a/src/Controller/Admin/CustomerController.php b/src/Controller/Admin/CustomerController.php index 7774a896..a134d97e 100644 --- a/src/Controller/Admin/CustomerController.php +++ b/src/Controller/Admin/CustomerController.php @@ -26,8 +26,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage activities in the admin part of the site. * * @Route(path="/admin/customer") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_customer')") */ class CustomerController extends AbstractController { @@ -55,6 +54,11 @@ class CustomerController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"}) + * @Security("is_granted('view_customer')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -81,6 +85,10 @@ class CustomerController extends AbstractController /** * @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"}) + * @Security("is_granted('create_customer')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -95,6 +103,10 @@ class CustomerController extends AbstractController /** * @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', customer)") + * + * @param Customer $customer + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function editAction(Customer $customer, Request $request) { @@ -102,35 +114,6 @@ class CustomerController extends AbstractController } /** - * @param Customer $customer - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - protected function renderCustomerForm(Customer $customer, Request $request) - { - $editForm = $this->createEditForm($customer); - - $editForm->handleRequest($request); - - if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($customer); - $entityManager->flush(); - - $this->flashSuccess('action.update.success'); - - return $this->redirectToRoute('admin_customer'); - } - - return $this->render('admin/customer_edit.html.twig', [ - 'customer' => $customer, - 'form' => $editForm->createView() - ]); - } - - /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', customer)") * @@ -179,6 +162,33 @@ class CustomerController extends AbstractController ]); } + /** + * @param Customer $customer + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + protected function renderCustomerForm(Customer $customer, Request $request) + { + $editForm = $this->createEditForm($customer); + + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($customer); + $entityManager->flush(); + + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_customer'); + } + + return $this->render('admin/customer_edit.html.twig', [ + 'customer' => $customer, + 'form' => $editForm->createView() + ]); + } + /** * @param CustomerQuery $query * @return \Symfony\Component\Form\FormInterface diff --git a/src/Controller/Admin/ProjectController.php b/src/Controller/Admin/ProjectController.php index ccfb6824..01e84b88 100644 --- a/src/Controller/Admin/ProjectController.php +++ b/src/Controller/Admin/ProjectController.php @@ -28,8 +28,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage projects in the admin part of the site. * * @Route(path="/admin/project") - * @Security("is_granted('ROLE_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_project')") */ class ProjectController extends AbstractController { @@ -45,6 +44,11 @@ class ProjectController extends AbstractController * @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"}) * @Cache(smaxage="10") + * @Security("is_granted('view_project')") + * + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ public function indexAction($page, Request $request) { @@ -72,6 +76,10 @@ class ProjectController extends AbstractController /** * @Route(path="/create", name="admin_project_create", methods={"GET", "POST"}) + * @Security("is_granted('create_project')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -81,6 +89,10 @@ class ProjectController extends AbstractController /** * @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', project)") + * + * @param Project $project + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function editAction(Project $project, Request $request) { @@ -88,8 +100,6 @@ class ProjectController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', project)") * diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/Admin/TimesheetController.php index 6a0137d2..b35ab296 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/Admin/TimesheetController.php @@ -24,8 +24,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used for manage timesheet entries in the admin part of the site. * * @Route(path="/team/timesheet") - * @Security("is_granted('ROLE_TEAMLEAD')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_other_timesheet')") */ class TimesheetController extends AbstractController { @@ -41,10 +40,9 @@ class TimesheetController extends AbstractController } /** - * This route shows all users timesheet entries. - * * @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"}) + * @Security("is_granted('view_other_timesheet')") * * @param $page * @param Request $request @@ -81,8 +79,6 @@ class TimesheetController extends AbstractController } /** - * The route to stop a running entry. - * * @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"}) * @Security("is_granted('stop', entry)") * @@ -95,8 +91,6 @@ class TimesheetController extends AbstractController } /** - * The route to edit an existing entry. - * * @Route(path="/{id}/edit", name="admin_timesheet_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', entry)") * @@ -110,9 +104,8 @@ class TimesheetController extends AbstractController } /** - * The route to create a new entry by form. - * * @Route(path="/create", name="admin_timesheet_create", methods={"GET", "POST"}) + * @Security("is_granted('create_other_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -123,8 +116,6 @@ class TimesheetController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', entry)") * @@ -157,7 +148,8 @@ class TimesheetController extends AbstractController 'action' => $this->generateUrl('admin_timesheet_create'), 'method' => 'POST', 'duration_only' => $this->isDurationOnlyMode(), - 'include_user' => true + 'include_rate' => $this->isGranted('edit_rate', $entry), + 'include_user' => true, ]); } @@ -175,7 +167,8 @@ class TimesheetController extends AbstractController ]), 'method' => 'POST', 'duration_only' => $this->isDurationOnlyMode(), - 'include_user' => true + 'include_rate' => $this->isGranted('edit_rate', $entry), + 'include_user' => true, ]); } diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index 8eb428e6..36adf7f4 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -25,8 +25,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * Controller used to manage users in the admin part of the site. * * @Route(path="/admin/user") - * @Security("is_granted('ROLE_SUPER_ADMIN')") - * @Security("is_granted('IS_AUTHENTICATED_FULLY')") + * @Security("is_granted('view_user')") */ class UserController extends AbstractController { @@ -54,7 +53,8 @@ class UserController extends AbstractController /** * @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"}) - + * @Security("is_granted('view_user')") + * * @param int $page * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -84,7 +84,10 @@ class UserController extends AbstractController /** * @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) { @@ -124,8 +127,6 @@ class UserController extends AbstractController } /** - * The route to delete an existing user. - * * @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', userToDelete)") * @@ -136,6 +137,7 @@ class UserController extends AbstractController */ public function deleteAction(User $userToDelete, Request $request) { + // $userToDelete MUST not be called $user, as $user is always the current user! $stats = $this->getDoctrine()->getRepository(Timesheet::class)->getUserStatistics($userToDelete); $deleteForm = $this->createFormBuilder() diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 01518c84..2dbfc338 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -28,7 +28,7 @@ use Symfony\Component\Routing\Annotation\Route; * Controller used to manage invoices. * * @Route(path="/invoice") - * @Security("is_granted('ROLE_TEAMLEAD')") + * @Security("is_granted('view_invoice') or is_granted('view_invoice_template')") */ class InvoiceController extends AbstractController { @@ -77,7 +77,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/", name="invoice", methods={"GET", "POST"}) - * @Security("is_granted('view', 'invoice')") + * @Security("is_granted('view_invoice')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -111,7 +111,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/print", name="invoice_print", methods={"GET", "POST"}) - * @Security("is_granted('create', 'invoice')") + * @Security("is_granted('create_invoice')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -212,7 +212,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"}) * @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"}) - * @Security("is_granted('view', 'invoice_template')") + * @Security("is_granted('view_invoice_template')") * * @param $page * @return \Symfony\Component\HttpFoundation\Response @@ -244,7 +244,7 @@ class InvoiceController extends AbstractController /** * @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"}) * @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"}) - * @Security("is_granted('create', 'invoice_template')") + * @Security("is_granted('create_invoice_template')") * * @param Request $request * @param InvoiceTemplate|null $template diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 44e5cf9d..f68fccb4 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Entity\Timesheet; use App\Entity\User; +use App\Event\PrepareUserEvent; use App\Form\UserApiTokenType; use App\Form\UserEditType; use App\Form\UserPasswordType; @@ -19,6 +20,7 @@ use App\Form\UserRolesType; use App\Repository\TimesheetRepository; use App\Voter\UserVoter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Form; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; @@ -28,10 +30,15 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * User profile controller * * @Route(path="/profile") - * @Security("is_granted('ROLE_USER')") + * @Security("is_granted('view_own_profile') or is_granted('view_other_profile')") */ class ProfileController extends AbstractController { + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; + /** * @var UserPasswordEncoderInterface */ @@ -40,9 +47,10 @@ class ProfileController extends AbstractController /** * @param UserPasswordEncoderInterface $encoder */ - public function __construct(UserPasswordEncoderInterface $encoder) + public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher) { $this->encoder = $encoder; + $this->dispatcher = $dispatcher; } /** @@ -162,6 +170,10 @@ class ProfileController extends AbstractController */ public function savePreferencesAction(User $profile, Request $request) { + // we need to prepare the user preferences, which is done via an EventSubscriber + $event = new PrepareUserEvent($profile); + $this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + $original = []; foreach ($profile->getPreferences() as $preference) { $original[$preference->getName()] = $preference; @@ -194,8 +206,11 @@ class ProfileController extends AbstractController $this->flashSuccess('action.update.success'); - // switch locale if neccessary - $locale = $profile->getPreferenceValue('language', $request->getLocale()); + // switch locale ONLY if updated profile is the current user + $locale = $request->getLocale(); + if ($this->getUser()->getId() === $profile->getId()) { + $locale = $profile->getPreferenceValue('language', $locale); + } return $this->redirectToRoute('user_profile_preferences', [ '_locale' => $locale, @@ -269,6 +284,10 @@ class ProfileController extends AbstractController */ private function createPreferencesForm(User $user) { + // we need to prepare the user preferences, which is done via an EventSubscriber + $event = new PrepareUserEvent($user); + $this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + return $this->createForm( UserPreferencesForm::class, $user, diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index b0421f8d..f7674a77 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -14,7 +14,6 @@ use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; -use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; @@ -24,7 +23,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; * Controller used to manage timesheets. * * @Route(path="/timesheet") - * @Security("is_granted('ROLE_USER')") + * @Security("is_granted('view_own_timesheet')") */ class TimesheetController extends AbstractController { @@ -41,7 +40,7 @@ class TimesheetController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"}) - * @Cache(smaxage="10") + * @Security("is_granted('view_own_timesheet')") * * @param int $page * @param Request $request @@ -81,6 +80,7 @@ class TimesheetController extends AbstractController /** * @Route(path="/export", name="timesheet_export", methods={"GET"}) + * @Security("is_granted('export_own_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\Response @@ -114,8 +114,6 @@ class TimesheetController extends AbstractController } /** - * The "main button and fly-out" for displaying (and stopping) active entries. - * * @return \Symfony\Component\HttpFoundation\Response */ public function activeEntriesAction() @@ -144,8 +142,6 @@ class TimesheetController extends AbstractController } /** - * The route to re-start a timesheet entry. - * * @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"}) * @Security("is_granted('start', timesheet)") * @@ -182,8 +178,6 @@ class TimesheetController extends AbstractController } /** - * The route to edit an existing entry. - * * @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', entry)") * @@ -201,9 +195,8 @@ class TimesheetController extends AbstractController } /** - * The route to create a new entry by form. - * * @Route(path="/create", name="timesheet_create", methods={"GET", "POST"}) + * @Security("is_granted('create_own_timesheet')") * * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response @@ -214,8 +207,6 @@ class TimesheetController extends AbstractController } /** - * The route to delete an existing entry. - * * @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', entry)") * @@ -247,6 +238,7 @@ class TimesheetController extends AbstractController return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('timesheet_create'), 'method' => 'POST', + 'include_rate' => $this->isGranted('edit_rate', $entry), 'duration_only' => $this->isDurationOnlyMode(), ]); } @@ -264,6 +256,7 @@ class TimesheetController extends AbstractController 'page' => $page ]), 'method' => 'POST', + 'include_rate' => $this->isGranted('edit_rate', $entry), 'duration_only' => $this->isDurationOnlyMode(), ]); } diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index 2ba74e4a..ab9e9be9 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -47,7 +47,6 @@ class UserFixtures extends Fixture private $encoder; /** - * AppFixtures constructor. * @param UserPasswordEncoderInterface $encoder */ public function __construct(UserPasswordEncoderInterface $encoder) diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index 6bfb37d7..1ed31399 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -40,11 +40,43 @@ class AppExtension extends Extension implements PrependExtensionInterface $container->setParameter('kimai.invoice.documents', $config['invoice']['documents']); $container->setParameter('kimai.defaults', $config['defaults']); + $this->createPermissionParameter($config['permissions'], $container); $this->createThemeParameter($config['theme'], $container); $this->createUserParameter($config['user'], $container); $this->createTimesheetParameter($config['timesheet'], $container); } + /** + * Performs some pre-compilation on the configured permissions from kimai.yaml + * to save us from constant array lookups from during runtime. + * + * @param array $config + * @param ContainerBuilder $container + */ + protected function createPermissionParameter(array $config, ContainerBuilder $container) + { + foreach ($config['maps'] as $role => $sets) { + if (!isset($config['roles'][$role])) { + $exception = new InvalidConfigurationException( + 'Configured permission set includes unknown role "' . $role . '"' + ); + $exception->setPath('kimai.permissions.maps.' . $role); + throw $exception; + } + foreach ($sets as $set) { + if (!isset($config['sets'][$set])) { + $exception = new InvalidConfigurationException( + 'Configured permission set "' . $set . '" for role "' . $role . '" is unknown' + ); + $exception->setPath('kimai.permissions.maps.' . $role); + throw $exception; + } + $config['roles'][$role] = array_unique(array_merge($config['roles'][$role], $config['sets'][$set])); + } + } + $container->setParameter('kimai.permissions', $config['roles']); + } + /** * @param array $config * @param ContainerBuilder $container diff --git a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php index d4a5feaa..b148eddd 100644 --- a/src/DependencyInjection/Compiler/DoctrineCompilerPass.php +++ b/src/DependencyInjection/Compiler/DoctrineCompilerPass.php @@ -23,8 +23,6 @@ class DoctrineCompilerPass implements CompilerPassInterface */ protected $allowedEngines = [ 'mysql', - 'oracle', - 'postgres', 'sqlite' ]; @@ -37,15 +35,6 @@ class DoctrineCompilerPass implements CompilerPassInterface { $engine = null; - // TODO - this does return the wrong connection. it used to be mysql, even if - // TODO - getenv('DATABASE_URL') returned an sqlite:// connection string - /* - $dbConfig = $container->getExtensionConfig('doctrine'); - if (isset($dbConfig[0]['dbal']['driver'])) { - $engine = str_replace('pdo_', '', $dbConfig[0]['dbal']['driver']); - } - */ - if (null === $engine) { $dbConfig = explode('://', getenv('DATABASE_URL')); $engine = $dbConfig['0'] ?: null; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 91034b95..f9e4bc8f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -40,6 +40,7 @@ class Configuration implements ConfigurationInterface ->append($this->getDashboardNode()) ->append($this->getWidgetsNode()) ->append($this->getDefaultsNode()) + ->append($this->getPermissionsNode()) ->end() ->end(); @@ -329,4 +330,51 @@ class Configuration implements ConfigurationInterface return $node; } + + protected function getPermissionsNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('permissions'); + + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('sets') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->arrayNode('maps') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->arrayNode('roles') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->performNoDeepMerging() + ->arrayPrototype() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->end() + ->end() + ; + + return $node; + } } diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 685fdcc0..bffe1f31 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -69,6 +69,11 @@ class UserPreference */ protected $type; + /** + * @var bool + */ + protected $enabled = true; + /** * @var Constraint[] */ @@ -181,6 +186,25 @@ class UserPreference return $this->type; } + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @param bool $enabled + * @return UserPreference + */ + public function setEnabled(bool $enabled) + { + $this->enabled = $enabled; + + return $this; + } + /** * Set the constraints which are used for validation of the value. * diff --git a/src/Event/PrepareUserEvent.php b/src/Event/PrepareUserEvent.php new file mode 100644 index 00000000..11dc12a7 --- /dev/null +++ b/src/Event/PrepareUserEvent.php @@ -0,0 +1,42 @@ +user = $user; + } + + /** + * @return User + */ + public function getUser() + { + return $this->user; + } +} diff --git a/src/Event/UserPreferenceEvent.php b/src/Event/UserPreferenceEvent.php index d6141fc1..33bd2bb8 100644 --- a/src/Event/UserPreferenceEvent.php +++ b/src/Event/UserPreferenceEvent.php @@ -14,7 +14,7 @@ use App\Entity\UserPreference; use Symfony\Component\EventDispatcher\Event; /** - * Class UserPreferenceEvent + * This event should be used, if further user preferences should added dynamically */ class UserPreferenceEvent extends Event { @@ -30,7 +30,6 @@ class UserPreferenceEvent extends Event protected $preferences; /** - * UserPreferenceEvent constructor. * @param User $user * @param UserPreference[] $preferences */ @@ -41,6 +40,7 @@ class UserPreferenceEvent extends Event } /** + * Do not set the preferences directly to the user object, but ONLY via addUserPreference() * @return User */ public function getUser() diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index 8f20641e..fc712100 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -53,26 +53,23 @@ class MenuSubscriber implements EventSubscriberInterface { $auth = $this->security; - $isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED'); - $isUser = $isLoggedIn && $auth->isGranted('ROLE_USER'); - $isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_TEAMLEAD'); - - if (!$isLoggedIn || !$isUser) { + if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) { return; } $menu = $event->getMenu(); - $menu->addItem( - new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock') - ); - if (!$isTeamlead) { - return; + if ($auth->isGranted('view_own_timesheet')) { + $menu->addItem( + new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock') + ); } - $menu->addItem( - new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice') - ); + if ($auth->isGranted('view_invoice')) { + $menu->addItem( + new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice') + ); + } } /** @@ -80,33 +77,42 @@ class MenuSubscriber implements EventSubscriberInterface */ public function onAdminMenuConfigure(ConfigureAdminMenuEvent $event) { - $menu = $event->getAdminMenu(); $auth = $this->security; - if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED') || !$auth->isGranted('ROLE_TEAMLEAD')) { + if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) { return; } - $menu->addChild( - new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock') - ); + $menu = $event->getAdminMenu(); - if (!$auth->isGranted('ROLE_ADMIN')) { - return; + if ($auth->isGranted('view_other_timesheet')) { + $menu->addChild( + new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock') + ); } - if ($auth->isGranted('ROLE_SUPER_ADMIN')) { + if ($auth->isGranted('view_user')) { $menu->addChild( new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], 'fas fa-user') ); } - $menu->addChild( - new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users') - )->addChild( - new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram') - )->addChild( - new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks') - ); + if ($auth->isGranted('view_customer')) { + $menu->addChild( + new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users') + ); + } + + if ($auth->isGranted('view_project')) { + $menu->addChild( + new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram') + ); + } + + if ($auth->isGranted('view_activity')) { + $menu->addChild( + new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks') + ); + } } } diff --git a/src/EventSubscriber/UserPreferenceSubscriber.php b/src/EventSubscriber/UserPreferenceSubscriber.php index 88d63aa3..b6a8e879 100644 --- a/src/EventSubscriber/UserPreferenceSubscriber.php +++ b/src/EventSubscriber/UserPreferenceSubscriber.php @@ -11,6 +11,7 @@ namespace App\EventSubscriber; use App\Entity\User; use App\Entity\UserPreference; +use App\Event\PrepareUserEvent; use App\Event\UserPreferenceEvent; use App\Form\Type\CalendarViewType; use App\Form\Type\LanguageType; @@ -19,15 +20,10 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; -use Symfony\Component\Form\Extension\Core\Type\TimezoneType; -use Symfony\Component\HttpKernel\Event\KernelEvent; -use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Validator\Constraints\Range; -/** - * Class UserPreferenceSubscriber - */ class UserPreferenceSubscriber implements EventSubscriberInterface { /** @@ -35,20 +31,26 @@ class UserPreferenceSubscriber implements EventSubscriberInterface */ protected $eventDispatcher; + /** + * @var AuthorizationCheckerInterface + */ + protected $voter; + /** * @var TokenStorageInterface */ protected $storage; /** - * UserPreferenceSubscriber constructor. * @param EventDispatcherInterface $dispatcher * @param TokenStorageInterface $storage + * @param AuthorizationCheckerInterface $voter */ - public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage) + public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage, AuthorizationCheckerInterface $voter) { $this->eventDispatcher = $dispatcher; $this->storage = $storage; + $this->voter = $voter; } /** @@ -57,27 +59,37 @@ class UserPreferenceSubscriber implements EventSubscriberInterface public static function getSubscribedEvents(): array { return [ - KernelEvents::CONTROLLER => ['loadUserPreferences', 200] + PrepareUserEvent::PREPARE => ['loadUserPreferences', 200] ]; } /** + * @param User $user * @return UserPreference[] */ - public function getDefaultPreferences() + public function getDefaultPreferences(User $user) { + $enableHourlyRate = false; + + if ($this->voter->isGranted('hourly-rate', $user)) { + $enableHourlyRate = true; + } + + /* + (new UserPreference()) + ->setName('timezone') + ->setValue(date_default_timezone_get()) + ->setType(TimezoneType::class), + */ + return [ (new UserPreference()) ->setName(UserPreference::HOURLY_RATE) ->setValue(0) ->setType(IntegerType::class) + ->setEnabled($enableHourlyRate) ->addConstraint(new Range(['min' => 0])), -/* - (new UserPreference()) - ->setName('timezone') - ->setValue(date_default_timezone_get()) - ->setType(TimezoneType::class), -*/ + (new UserPreference()) ->setName('language') ->setValue('en') // TODO fetch from services.yaml @@ -116,31 +128,32 @@ class UserPreferenceSubscriber implements EventSubscriberInterface } /** - * @param KernelEvent $event + * @param PrepareUserEvent $event */ - public function loadUserPreferences(KernelEvent $event) + public function loadUserPreferences(PrepareUserEvent $event) { if (!$this->canHandleEvent($event)) { return; } - /** @var User $user */ - $user = $this->storage->getToken()->getUser(); + $user = $event->getUser(); $prefs = []; foreach ($user->getPreferences() as $preference) { $prefs[$preference->getName()] = $preference; } - $event = new UserPreferenceEvent($user, $this->getDefaultPreferences()); + $event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user)); $this->eventDispatcher->dispatch(UserPreferenceEvent::CONFIGURE, $event); foreach ($event->getPreferences() as $preference) { + /* @var UserPreference[] $prefs */ if (isset($prefs[$preference->getName()])) { /* @var UserPreference $pref */ $prefs[$preference->getName()] ->setType($preference->getType()) ->setConstraints($preference->getConstraints()) + ->setEnabled($preference->isEnabled()) ; } else { $prefs[$preference->getName()] = $preference; @@ -151,24 +164,15 @@ class UserPreferenceSubscriber implements EventSubscriberInterface } /** - * @param KernelEvent $event + * @param PrepareUserEvent $event * @return bool */ - protected function canHandleEvent(KernelEvent $event): bool + protected function canHandleEvent(PrepareUserEvent $event): bool { - // Ignore sub-requests - if (!$event->isMasterRequest()) { + if (null === ($user = $event->getUser())) { return false; } - // ignore events like the toolbar where we do not have a token - if (null === $this->storage->getToken()) { - return false; - } - - /** @var User $user */ - $user = $this->storage->getToken()->getUser(); - return ($user instanceof User); } } diff --git a/src/EventSubscriber/UserProfileSubscriber.php b/src/EventSubscriber/UserProfileSubscriber.php new file mode 100644 index 00000000..3b7d2ba3 --- /dev/null +++ b/src/EventSubscriber/UserProfileSubscriber.php @@ -0,0 +1,89 @@ +eventDispatcher = $dispatcher; + $this->storage = $storage; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER => ['prepareUserProfile', 200] + ]; + } + + /** + * @param KernelEvent $event + */ + public function prepareUserProfile(KernelEvent $event) + { + if (!$this->canHandleEvent($event)) { + return; + } + + /** @var User $user */ + $user = $this->storage->getToken()->getUser(); + + $event = new PrepareUserEvent($user); + $this->eventDispatcher->dispatch(PrepareUserEvent::PREPARE, $event); + } + + /** + * @param KernelEvent $event + * @return bool + */ + protected function canHandleEvent(KernelEvent $event): bool + { + // Ignore sub-requests + if (!$event->isMasterRequest()) { + return false; + } + + // ignore events like the toolbar where we do not have a token + if (null === $this->storage->getToken()) { + return false; + } + + /** @var User $user */ + $user = $this->storage->getToken()->getUser(); + + return ($user instanceof User); + } +} diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index c04e7195..94d20158 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -133,15 +133,20 @@ class TimesheetEditForm extends AbstractType 'label' => 'label.description', 'required' => false, ]) - ->add('fixedRate', NumberType::class, [ - 'label' => 'label.fixed_rate', - 'required' => false, - ]) - ->add('hourlyRate', NumberType::class, [ - 'label' => 'label.hourly_rate', - 'required' => false, - ]) ; + + if ($options['include_rate']) { + $builder + ->add('fixedRate', NumberType::class, [ + 'label' => 'label.fixed_rate', + 'required' => false, + ]) + ->add('hourlyRate', NumberType::class, [ + 'label' => 'label.hourly_rate', + 'required' => false, + ]); + } + /* $builder->get('customer')->addEventListener( FormEvents::POST_SUBMIT, @@ -162,6 +167,7 @@ class TimesheetEditForm extends AbstractType } ); */ + $builder->get('project')->addEventListener( FormEvents::POST_SUBMIT, function (FormEvent $event) { @@ -192,6 +198,7 @@ class TimesheetEditForm extends AbstractType 'csrf_token_id' => 'timesheet_edit', 'duration_only' => false, 'include_user' => false, + 'include_rate' => true, 'docu_chapter' => 'timesheet', ]); } diff --git a/src/Form/Type/UserPreferenceType.php b/src/Form/Type/UserPreferenceType.php index ce7fee72..3390a80a 100644 --- a/src/Form/Type/UserPreferenceType.php +++ b/src/Form/Type/UserPreferenceType.php @@ -35,23 +35,31 @@ class UserPreferenceType extends AbstractType /** @var UserPreference $preference */ $preference = $event->getData(); - if ($preference instanceof UserPreference) { - // prevents unconfigured values from showing up in the form - if ($preference->getType() === null) { - return; - } - - $required = true; - if (CheckboxType::class == $preference->getType()) { - $required = false; - } - - $event->getForm()->add('value', $preference->getType(), [ - 'label' => 'label.' . $preference->getName(), - 'constraints' => $preference->getConstraints(), - 'required' => $required, - ]); + if (!($preference instanceof UserPreference)) { + return; } + + // prevents unconfigured values from showing up in the form + if ($preference->getType() === null) { + return; + } + + $required = true; + if (CheckboxType::class == $preference->getType()) { + $required = false; + } + + $type = $preference->getType(); + if (!$preference->isEnabled()) { + $type = HiddenType::class; + } + + $event->getForm()->add('value', $type, [ + 'label' => 'label.' . $preference->getName(), + 'constraints' => $preference->getConstraints(), + 'required' => $required, + 'disabled' => !$preference->isEnabled(), + ]); } ); $builder->add('name', HiddenType::class); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 7592bc8d..191014ea 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -27,6 +27,18 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface return $this->find($id); } + /** + * Overwritten to fetch preferences when using the Profile controller actions. + * Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user. + */ + public function findOneBy(array $criteria, array $orderBy = null) + { + if (count($criteria) == 1 && isset($criteria['username'])) { + return $this->loadUserByUsername($criteria['username']); + } + + return parent::findOneBy($criteria, $orderBy); + } /** * @return int */ @@ -43,7 +55,6 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface { $qb = $this->getEntityManager()->createQueryBuilder(); - // if we join activities, the maxperpage limit will limit the list to the amount or projects + activties $qb->select('u') ->from(User::class, 'u') ->orderBy('u.' . $query->getOrderBy(), $query->getOrder()); @@ -77,6 +88,8 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface public function loadUserByUsername($username) { return $this->createQueryBuilder('u') + ->select('u', 'p') + ->leftJoin('u.preferences', 'p') ->where('u.username = :username') ->orWhere('u.email = :username') ->setParameter('username', $username) diff --git a/src/Security/RolePermissionManager.php b/src/Security/RolePermissionManager.php new file mode 100644 index 00000000..38c2fb2a --- /dev/null +++ b/src/Security/RolePermissionManager.php @@ -0,0 +1,67 @@ +permissions = $permissions; + + foreach ($permissions as $role => $perms) { + $this->knownPermissions = array_merge($this->knownPermissions, $perms); + } + $this->knownPermissions = array_unique($this->knownPermissions); + } + + /** + * @param string $permission + * @return bool + */ + public function isRegisteredPermission($permission) + { + return in_array($permission, $this->knownPermissions); + } + + /** + * @param string $role + * @return bool + */ + public function roleHasPermission($role) + { + return isset($this->permissions[$role]); + } + + /** + * @param string $role + * @param string $permission + * @return bool + */ + public function hasPermission($role, $permission) + { + if (!isset($this->permissions[$role])) { + return false; + } + + return in_array($permission, $this->permissions[$role]); + } +} diff --git a/src/Voter/AbstractVoter.php b/src/Voter/AbstractVoter.php index b9eda969..12039ba7 100644 --- a/src/Voter/AbstractVoter.php +++ b/src/Voter/AbstractVoter.php @@ -9,12 +9,14 @@ namespace App\Voter; +use App\Entity\User; use App\Security\AclDecisionManager; +use App\Security\RolePermissionManager; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** - * Abstract voter to help with checking user roles. + * Abstract voter to help with checking user permissions. */ abstract class AbstractVoter extends Voter { @@ -22,14 +24,19 @@ abstract class AbstractVoter extends Voter * @var AclDecisionManager */ protected $decisionManager; + /** + * @var RolePermissionManager + */ + protected $roleManager; /** - * AbstractVoter constructor. * @param AclDecisionManager $decisionManager + * @param RolePermissionManager $roleManager */ - public function __construct(AclDecisionManager $decisionManager) + public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager) { $this->decisionManager = $decisionManager; + $this->roleManager = $roleManager; } /** @@ -50,4 +57,39 @@ abstract class AbstractVoter extends Voter { return $this->decisionManager->hasRole($token, [$role]); } + + /** + * @param string $role + * @param string $permission + * @return bool + */ + protected function hasPermission($role, $permission) + { + return $this->roleManager->hasPermission($role, $permission); + } + + /** + * @param User $user + * @param string $permission + * @return bool + */ + protected function hasRolePermission(User $user, $permission) + { + foreach ($user->getRoles() as $role) { + if ($this->hasPermission($role, $permission)) { + return true; + } + } + + return false; + } + + /** + * @param string $permission + * @return bool + */ + public function isRegisteredPermission($permission) + { + return $this->roleManager->isRegisteredPermission($permission); + } } diff --git a/src/Voter/ActivityVoter.php b/src/Voter/ActivityVoter.php index e1d37b95..91d099ba 100644 --- a/src/Voter/ActivityVoter.php +++ b/src/Voter/ActivityVoter.php @@ -22,6 +22,9 @@ class ActivityVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Activity) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class ActivityVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Activity $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Activity) { return false; } - if (!$subject instanceof Activity) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class ActivityVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Activity) { + return $this->hasRolePermission($user, $attribute . '_activity'); } return false; } - - /** - * @param Activity $activity - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Activity $activity, User $user, TokenInterface $token) - { - if ($this->canEdit($activity, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Activity $activity - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Activity $activity, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/CustomerVoter.php b/src/Voter/CustomerVoter.php index 667eca70..5b3c6402 100644 --- a/src/Voter/CustomerVoter.php +++ b/src/Voter/CustomerVoter.php @@ -22,6 +22,9 @@ class CustomerVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Customer) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class CustomerVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Customer $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Customer) { return false; } - if (!$subject instanceof Customer) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class CustomerVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Customer) { + return $this->hasRolePermission($user, $attribute . '_customer'); } return false; } - - /** - * @param Customer $customer - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Customer $customer, User $user, TokenInterface $token) - { - if ($this->canEdit($customer, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Customer $customer - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Customer $customer, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/InvoiceTemplateVoter.php b/src/Voter/InvoiceTemplateVoter.php new file mode 100644 index 00000000..44a36414 --- /dev/null +++ b/src/Voter/InvoiceTemplateVoter.php @@ -0,0 +1,72 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + if ($subject instanceof InvoiceTemplate) { + return $this->hasRolePermission($user, $attribute . '_invoice_template'); + } + + return false; + } +} diff --git a/src/Voter/InvoiceVoter.php b/src/Voter/InvoiceVoter.php deleted file mode 100644 index e3357f18..00000000 --- a/src/Voter/InvoiceVoter.php +++ /dev/null @@ -1,136 +0,0 @@ -getUser(); - - if (!$user instanceof User) { - return false; - } - - switch ($attribute) { - case self::VIEW: - return $this->canView($user, $token); - case self::CREATE: - return $this->canCreate($user, $token); - case self::EDIT: - return $this->canEdit($user, $token); - case self::DELETE: - return $this->canDelete($token); - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(User $user, TokenInterface $token) - { - if ($this->canEdit($user, $token)) { - return true; - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canCreate(User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_TEAMLEAD', $token); - } -} diff --git a/src/Voter/ProjectVoter.php b/src/Voter/ProjectVoter.php index e8bfb5c2..4b26e6da 100644 --- a/src/Voter/ProjectVoter.php +++ b/src/Voter/ProjectVoter.php @@ -22,6 +22,9 @@ class ProjectVoter extends AbstractVoter public const EDIT = 'edit'; public const DELETE = 'delete'; + /** + * support rules based on the given $subject (here: Project) + */ public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, @@ -30,16 +33,16 @@ class ProjectVoter extends AbstractVoter /** * @param string $attribute - * @param mixed $subject + * @param Project $subject * @return bool */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Project) { return false; } - if (!$subject instanceof Project) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -60,54 +63,10 @@ class ProjectVoter extends AbstractVoter return false; } - switch ($attribute) { - case self::VIEW: - return $this->canView($subject, $user, $token); - case self::EDIT: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($token); + if ($subject instanceof Project) { + return $this->hasRolePermission($user, $attribute . '_project'); } return false; } - - /** - * @param Project $project - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Project $project, User $user, TokenInterface $token) - { - if ($this->canEdit($project, $user, $token)) { - return true; - } - - return false; - } - - /** - * @param Project $project - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Project $project, User $user, TokenInterface $token) - { - if ($this->canDelete($token)) { - return true; - } - - return false; - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/RolePermissionVoter.php b/src/Voter/RolePermissionVoter.php new file mode 100644 index 00000000..8d65d58d --- /dev/null +++ b/src/Voter/RolePermissionVoter.php @@ -0,0 +1,64 @@ +isRegisteredPermission($attribute); + } + + return false; + } + + /** + * @param string $attribute + * @param Activity $subject + * @param TokenInterface $token + * @return bool + */ + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + $user = $token->getUser(); + + if (!($user instanceof User)) { + return false; + } + + foreach ($user->getRoles() as $role) { + if ($this->hasPermission($role, $attribute)) { + return true; + } + } + + return false; + } +} diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index efc6c6e8..efa21201 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -21,16 +21,21 @@ class TimesheetVoter extends AbstractVoter { public const START = 'start'; public const STOP = 'stop'; - public const VIEW = 'view'; public const EDIT = 'edit'; public const DELETE = 'delete'; + public const VIEW_RATE = 'view_rate'; + public const EDIT_RATE = 'edit_rate'; + /** + * support rules based on the given $subject (here: Timesheet) + */ public const ALLOWED_ATTRIBUTES = [ self::START, self::STOP, - self::VIEW, self::EDIT, - self::DELETE + self::DELETE, + self::VIEW_RATE, + self::EDIT_RATE, ]; /** @@ -40,11 +45,11 @@ class TimesheetVoter extends AbstractVoter */ protected function supports($attribute, $subject) { - if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { + if (!$subject instanceof Timesheet) { return false; } - if (!$subject instanceof Timesheet) { + if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { return false; } @@ -61,40 +66,48 @@ class TimesheetVoter extends AbstractVoter { $user = $token->getUser(); - if (!$user instanceof User) { + if (!($user instanceof User)) { return false; } - switch ($attribute) { - case self::STOP: - return $this->canStop($subject, $user, $token); - - case self::START: - return $this->canStart($subject, $user, $token); - - case self::VIEW: - return $this->canView($subject, $user, $token); - - case self::EDIT: - return $this->canEdit($subject, $user, $token); - - case self::DELETE: - return $this->canDelete($subject, $user, $token); + if (!($subject instanceof Timesheet)) { + return false; } - return false; - } + $permission = ''; - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canStop(Timesheet $timesheet, User $user, TokenInterface $token) - { - // if a teamlead stops an entry for another user, check that this user is part of his team - return $this->isOwnOrTeamlead($timesheet, $user, $token); + switch ($attribute) { + case self::START: + if (!$this->canStart($subject, $user, $token)) { + return false; + } + $permission .= $attribute; + break; + + case self::VIEW_RATE: + case self::EDIT_RATE: + case self::STOP: + case self::EDIT: + case self::DELETE: + $permission .= $attribute; + break; + + default: + return false; + } + + $permission .= '_'; + + // extend me for "team" support later on + if ($subject->getUser()->getId() == $user->getId()) { + $permission .= 'own'; + } else { + $permission .= 'other'; + } + + $permission .= '_timesheet'; + + return $this->hasRolePermission($user, $permission); } /** @@ -118,65 +131,4 @@ class TimesheetVoter extends AbstractVoter return true; } - - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canView(Timesheet $timesheet, User $user, TokenInterface $token) - { - return $this->isOwnOrTeamlead($timesheet, $user, $token); - } - - /** - * @param Timesheet $timesheet - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEdit(Timesheet $timesheet, User $user, TokenInterface $token) - { - return $this->isOwnOrTeamlead($timesheet, $user, $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canDelete(Timesheet $timesheet, User $user, TokenInterface $token) - { - if (!$this->isFullyAuthenticated($token)) { - return false; - } - - return $this->isOwnOrAdmin($timesheet, $user, $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function isOwnOrTeamlead(Timesheet $timesheet, User $user, TokenInterface $token) - { - if ($timesheet->getUser()->getId() == $user->getId()) { - return true; - } - - return $this->hasRole('ROLE_TEAMLEAD', $token); - } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function isOwnOrAdmin(Timesheet $timesheet, User $user, TokenInterface $token) - { - if ($timesheet->getUser()->getId() == $user->getId()) { - return true; - } - - return $this->hasRole('ROLE_ADMIN', $token); - } } diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php index bda84e76..be8650bf 100644 --- a/src/Voter/UserVoter.php +++ b/src/Voter/UserVoter.php @@ -19,22 +19,22 @@ class UserVoter extends AbstractVoter { public const VIEW = 'view'; public const EDIT = 'edit'; - public const CREATE = 'create'; public const DELETE = 'delete'; public const PASSWORD = 'password'; public const ROLES = 'roles'; public const PREFERENCES = 'preferences'; public const API_TOKEN = 'api-token'; + public const HOURLY_RATE = 'hourly-rate'; public const ALLOWED_ATTRIBUTES = [ self::VIEW, self::EDIT, - self::CREATE, self::ROLES, self::PASSWORD, self::DELETE, self::PREFERENCES, self::API_TOKEN, + self::HOURLY_RATE, ]; /** @@ -48,7 +48,7 @@ class UserVoter extends AbstractVoter return false; } - if (!$subject instanceof User) { + if (!($subject instanceof User)) { return false; } @@ -65,66 +65,48 @@ class UserVoter extends AbstractVoter { $user = $token->getUser(); - if (!$user instanceof User) { + if (!($user instanceof User)) { return false; } + $permission = ''; + switch ($attribute) { + // special case for the UserController + case self::DELETE: + if (!$this->canDelete($subject, $user, $token)) { + return false; + } + + return $this->hasRolePermission($user, 'delete_user'); + + // used in templates and ProfileController case self::VIEW: - return $this->canView($subject, $user, $token); case self::EDIT: case self::API_TOKEN: case self::PASSWORD: - return $this->canEdit($subject, $user, $token); - case self::DELETE: - return $this->canDelete($subject, $user, $token); - case self::CREATE: // create actually passes in the current user as $subject, not the new one case self::ROLES: - return $this->canAdminUsers($token); case self::PREFERENCES: - return $this->canEditPreferences($subject, $user, $token); + case self::HOURLY_RATE: + $permission .= $attribute; + break; + + default: + return false; } - return false; - } + $permission .= '_'; - /** - * @param User $profile - * @param User $user - * @param TokenInterface $token - * @return bool - */ - protected function canEditPreferences(User $profile, User $user, TokenInterface $token) - { - return $profile->getId() === $user->getId(); - } - - /** - * @param User $profile - * @param User $user - * @return bool - */ - protected function canView(User $profile, User $user, TokenInterface $token) - { - if ($this->canEdit($profile, $user, $token)) { - return true; + // extend me for "team" support later on + if ($subject->getId() == $user->getId()) { + $permission .= 'own'; + } else { + $permission .= 'other'; } - return $profile->getId() === $user->getId(); - } + $permission .= '_profile'; - /** - * @param User $profile - * @param User $user - * @return bool - */ - protected function canEdit(User $profile, User $user, TokenInterface $token) - { - if ($this->canAdminUsers($token)) { - return true; - } - - return $profile->getId() === $user->getId(); + return $this->hasRolePermission($user, $permission); } /** @@ -134,19 +116,6 @@ class UserVoter extends AbstractVoter */ protected function canDelete(User $profile, User $user, TokenInterface $token) { - if (!$this->canAdminUsers($token)) { - return false; - } - return $profile->getId() !== $user->getId(); } - - /** - * @param TokenInterface $token - * @return bool - */ - protected function canAdminUsers(TokenInterface $token) - { - return $this->isFullyAuthenticated($token) && $this->hasRole(User::ROLE_SUPER_ADMIN, $token); - } } diff --git a/templates/admin/activity.html.twig b/templates/admin/activity.html.twig index a20ecb04..404d885b 100644 --- a/templates/admin/activity.html.twig +++ b/templates/admin/activity.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin', 'create': path('admin_activity_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin'} %} + {% if is_granted('create_activity') %} + {% set actions = actions|merge({'create': path('admin_activity_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseActivityAdmin', showFilter) }} diff --git a/templates/admin/customer.html.twig b/templates/admin/customer.html.twig index 3ffb98d0..a76d6fe3 100644 --- a/templates/admin/customer.html.twig +++ b/templates/admin/customer.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_customer.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseCustomerAdmin', 'visibility': '#modal_customer_admin', 'create': path('admin_customer_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseCustomerAdmin', 'visibility': '#modal_customer_admin'} %} + {% if is_granted('create_customer') %} + {% set actions = actions|merge({'create': path('admin_customer_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseCustomerAdmin', showFilter) }} diff --git a/templates/admin/project.html.twig b/templates/admin/project.html.twig index f32e8fdd..e3dd9c26 100644 --- a/templates/admin/project.html.twig +++ b/templates/admin/project.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_project.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseProjectAdmin', 'visibility': '#modal_project_admin', 'create': path('admin_project_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseProjectAdmin', 'visibility': '#modal_project_admin'} %} + {% if is_granted('create_project') %} + {% set actions = actions|merge({'create': path('admin_project_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseProjectAdmin', showFilter) }} diff --git a/templates/admin/timesheet.html.twig b/templates/admin/timesheet.html.twig index c1478d00..de8b42d4 100644 --- a/templates/admin/timesheet.html.twig +++ b/templates/admin/timesheet.html.twig @@ -5,7 +5,13 @@ {% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_timesheet.subtitle'|trans }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseTimesheetAdmin', 'visibility': '#modal_timesheet_admin', 'create': path('admin_timesheet_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseTimesheetAdmin', 'visibility': '#modal_timesheet_admin'} %} + {% if is_granted('create_other_timesheet') %} + {% set actions = actions|merge({'create': path('admin_timesheet_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseTimesheetAdmin', showFilter) }} @@ -50,15 +56,19 @@ {{ entry.end|date("H:i") }} {% endif %} {{ entry.duration|duration }} - {{ entry.rate|money(entry.project.customer.currency) }} {% else %} {% if not duration_only %} ‐ {% endif %} {{ entry|duration }} - ‐ {% endif %} - + + {% if not entry.end or not is_granted('view_rate', entry) %} + ‐ + {% else %} + {{ entry.rate|money(entry.project.customer.currency) }} + {% endif %} + {{ widgets.label_customer(entry.project.customer) }} diff --git a/templates/admin/user.html.twig b/templates/admin/user.html.twig index 166aafd5..d711e312 100644 --- a/templates/admin/user.html.twig +++ b/templates/admin/user.html.twig @@ -5,7 +5,14 @@ {% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_user.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseUserAdmin', 'visibility': '#modal_user_admin', 'create': path('admin_user_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseUserAdmin', 'visibility': '#modal_user_admin'} %} + {% if is_granted('create_user') %} + {% set actions = actions|merge({'create': path('admin_user_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} + {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseUserAdmin', showFilter) }} diff --git a/templates/invoice/index.html.twig b/templates/invoice/index.html.twig index 2739f4b5..3b1d284f 100644 --- a/templates/invoice/index.html.twig +++ b/templates/invoice/index.html.twig @@ -4,7 +4,17 @@ {% block page_title %}{{ 'invoice.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseInvoice', 'create': path('admin_invoice_template_create'), 'list': path('admin_invoice_template')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseInvoice'} %} + {% if is_granted('create_invoice_template') %} + {% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %} + {% endif %} + {% if is_granted('view_invoice_template') %} + {% set actions = actions|merge({'list': path('admin_invoice_template')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} + {% block page_content_class %}{{ parent() }} invoice{% endblock %} @@ -21,23 +31,24 @@ {% if model.entries is empty %} {{ widgets.callout('warning', 'invoice.select_filter') }} {% else %} - {{ include('invoice/preview.html.twig') }} -
-
- + {% if is_granted('create_invoice') %} +
+
+ +
-
- + {% endif %} {% endif %} {% endblock %} {% block javascripts %} {{ parent() }} + {% if is_granted('create_invoice') %} + {% endif %} {% endblock %} diff --git a/templates/invoice/templates.html.twig b/templates/invoice/templates.html.twig index a957d80d..7fe7fb41 100644 --- a/templates/invoice/templates.html.twig +++ b/templates/invoice/templates.html.twig @@ -4,7 +4,16 @@ {% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_invoice_template.subtitle'|trans }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'create': path('admin_invoice_template_create'), 'invoice': path('invoice')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {} %} + {% if is_granted('create_invoice_template') %} + {% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %} + {% endif %} + {% if is_granted('view_invoice') %} + {% set actions = actions|merge({'invoice': path('invoice')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main %} {% if entries.count == 0 %} @@ -26,9 +35,16 @@ {{ entry.dueDays }} {{ entry.vat }} - {% set actionButtons = {'edit': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page})} %} - {% set actionButtons = actionButtons|merge({'copy': path('admin_invoice_template_copy', {'id' : entry.id, 'page': page})}) %} - {% set actionButtons = actionButtons|merge({'trash': path('admin_invoice_template_delete', {'id' : entry.id, 'page': page})}) %} + {% set actionButtons = {} %} + {% if is_granted('edit', entry) %} + {% set actionButtons = {'edit': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page})} %} + {% endif %} + {% if is_granted('create_invoice_template') %} + {% set actionButtons = actionButtons|merge({'copy': path('admin_invoice_template_copy', {'id' : entry.id, 'page': page})}) %} + {% endif %} + {% if is_granted('delete', entry) %} + {% set actionButtons = actionButtons|merge({'trash': path('admin_invoice_template_delete', {'id' : entry.id, 'page': page})}) %} + {% endif %} {{ widgets.button_group(actionButtons) }} diff --git a/templates/sidebar/settings.html.twig b/templates/sidebar/settings.html.twig index d1e78c09..663eb277 100644 --- a/templates/sidebar/settings.html.twig +++ b/templates/sidebar/settings.html.twig @@ -1,10 +1,14 @@ {% import "@AdminLTE/Macros/default.html.twig" as macro %} {% import "macros/widgets.html.twig" as widgets %} +{% set linkUserProfile = path('user_profile', {'username' : user.username}) %} +{% if not is_granted('view_own_profile') %} + {% set linkUserProfile = '#' %} +{% endif %}
+ {% endif %}
  • diff --git a/templates/timesheet/index.html.twig b/templates/timesheet/index.html.twig index c9d7ea10..f88e1673 100644 --- a/templates/timesheet/index.html.twig +++ b/templates/timesheet/index.html.twig @@ -5,7 +5,18 @@ {% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'timesheet.subtitle'|trans }}{% endblock %} -{% block page_actions %}{{ widgets.page_actions({'filter': '#collapseTimesheet', 'download': 'onclick:return exportTimesheet()', 'visibility': '#modal_timesheet', 'calendar': path('calendar'), 'create': path('timesheet_create')}) }}{% endblock %} +{% block page_actions %} + {% set actions = {'filter': '#collapseTimesheet'} %} + {% if is_granted('export_own_timesheet') %} + {% set actions = actions|merge({'download': 'onclick:return exportTimesheet()'}) %} + {% endif %} + {% set actions = actions|merge({'visibility': '#modal_timesheet'}) %} + {% set actions = actions|merge({'calendar': path('calendar')}) %} + {% if is_granted('create_own_timesheet') %} + {% set actions = actions|merge({'create': path('timesheet_create')}) %} + {% endif %} + {{ widgets.page_actions(actions) }} +{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseTimesheet', showFilter) }} @@ -23,9 +34,13 @@ {% set columns = columns|merge({'starttime': '', 'endtime': 'hidden-xs'}) %} {% endif %} + {% set columns = columns|merge({'duration': ''}) %} + + {% if is_granted('view_rate_own_timesheet') %} + {% set columns = columns|merge({'rate': 'hidden-xs'}) %} + {% endif %} + {% set columns = columns|merge({ - 'duration': '', - 'rate': 'hidden-xs', 'customer': 'hidden-xs hidden-sm', 'project': 'hidden-xs hidden-sm', 'activity': 'hidden-xs hidden-sm', @@ -50,13 +65,17 @@ {{ entry.end|date("H:i") }} {% endif %} {{ entry.duration|duration }} - {{ entry.rate|money(entry.project.customer.currency) }} + {% if is_granted('view_rate', entry) %} + {{ entry.rate|money(entry.project.customer.currency) }} + {% endif %} {% else %} {% if not duration_only %} ‐ {% endif %} {{ entry|duration }} - ‐ + {% if is_granted('view_rate', entry) %} + ‐ + {% endif %} {% endif %} {{ widgets.label_customer(entry.project.customer) }} @@ -64,7 +83,10 @@ {{ widgets.label_activity(entry.activity) }} {{ entry.description|desc2html }} - {% set actionButtons = {'edit': path('timesheet_edit', {'id' : entry.id, 'page': page})} %} + {% set actionButtons = {} %} + {% if is_granted('edit', entry) %} + {% set actionButtons = actionButtons|merge({'edit': path('timesheet_edit', {'id' : entry.id, 'page': page})}) %} + {% endif %} {% if entry.end %} {% if is_granted('start', entry) %} @@ -76,7 +98,9 @@ {% endif %} {% endif %} - {% set actionButtons = actionButtons|merge({'trash': path('timesheet_delete', {'id' : entry.id, 'page': page})}) %} + {% if is_granted('delete', entry) %} + {% set actionButtons = actionButtons|merge({'trash': path('timesheet_delete', {'id' : entry.id, 'page': page})}) %} + {% endif %} {{ widgets.button_group(actionButtons) }} diff --git a/tests/Controller/Admin/UserControllerTest.php b/tests/Controller/Admin/UserControllerTest.php index bb18c40a..ad1d5f65 100644 --- a/tests/Controller/Admin/UserControllerTest.php +++ b/tests/Controller/Admin/UserControllerTest.php @@ -51,7 +51,7 @@ class UserControllerTest extends ControllerBaseTest $this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit')); $client->followRedirect(); - $expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#roles']; + $expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#roles', '#preferences']; $tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li'); $this->assertEquals(count($expectedTabs), $tabs->count()); diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index 8a585d3a..14e37794 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -225,7 +225,7 @@ class InvoiceControllerTest extends ControllerBaseTest public function testDeleteTemplateAction() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $em = $client->getContainer()->get('doctrine.orm.entity_manager'); $fixture = new InvoiceFixtures(); diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 33ebb0eb..455d0972 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -25,13 +25,24 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER); } - public function testIndexAction() + public function getTabTestData() { - $client = $this->getClientForAuthenticatedUser(); - $this->request($client, '/profile/' . UserFixtures::USERNAME_USER); - $this->assertTrue($client->getResponse()->isSuccessful()); + $userTabs = ['#charts', '#settings', '#password', '#api-token', '#preferences']; - $expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#preferences']; + return [ + [User::ROLE_USER, UserFixtures::USERNAME_USER, ['#charts', '#settings', '#password', '#api-token', '#preferences']], + [User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#roles'])], + ]; + } + + /** + * @dataProvider getTabTestData + */ + public function testIndexAction($role, $username, $expectedTabs) + { + $client = $this->getClientForAuthenticatedUser($role); + $this->request($client, '/profile/' . $username); + $this->assertTrue($client->getResponse()->isSuccessful()); $tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li'); $this->assertEquals(count($expectedTabs), $tabs->count()); @@ -208,16 +219,31 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertEquals(['ROLE_TEAMLEAD', 'ROLE_SUPER_ADMIN', 'ROLE_USER'], $user->getRoles()); } - public function testPreferencesAction() + public function getPreferencesTestData() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); - $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/prefs'); + return [ + // assert that the user doesn't have the "hourly-rate_own_profile" permission + [User::ROLE_USER, UserFixtures::USERNAME_USER, 82, 82, 'ar'], + // admins are allowed to update their own hourly rate + [User::ROLE_ADMIN, UserFixtures::USERNAME_ADMIN, 81, 37, 'ar'], + // admins are allowed to update other peoples hourly rate + [User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_USER, 82, 37, 'en'], + ]; + } + + /** + * @dataProvider getPreferencesTestData + */ + public function testPreferencesAction($role, $username, $hourlyRateOriginal, $hourlyRate, $expectedLocale) + { + $client = $this->getClientForAuthenticatedUser($role); + $this->request($client, '/profile/' . $username . '/prefs'); /** @var User $user */ $em = $client->getContainer()->get('doctrine.orm.entity_manager'); - $user = $this->getUserByRole($em, User::ROLE_USER); + $user = $this->getUserByName($em, $username); - $this->assertEquals(82, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); + $this->assertEquals($hourlyRateOriginal, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); $this->assertEquals('green', $user->getPreferenceValue(UserPreference::SKIN)); $this->assertEquals(true, $user->getPreferenceValue('theme.fixed_layout')); $this->assertEquals(false, $user->getPreferenceValue('theme.boxed_layout')); @@ -242,7 +268,7 @@ class ProfileControllerTest extends ControllerBaseTest ] ]); - $targetUrl = '/ar/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/prefs'; + $targetUrl = '/' . $expectedLocale . '/profile/' . urlencode($username) . '/prefs'; $this->assertIsRedirect($client, $targetUrl); $client->followRedirect(); @@ -251,9 +277,9 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertHasFlashSuccess($client); $em = $client->getContainer()->get('doctrine.orm.entity_manager'); - $user = $this->getUserByRole($em, User::ROLE_USER); + $user = $this->getUserByName($em, $username); - $this->assertEquals(37, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); + $this->assertEquals($hourlyRate, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); //$this->assertEquals('', $user->getPreferenceValue('America/Creston')); $this->assertEquals('ar', $user->getPreferenceValue('language')); $this->assertEquals('blue', $user->getPreferenceValue(UserPreference::SKIN)); diff --git a/tests/Controller/TimesheetControllerTest.php b/tests/Controller/TimesheetControllerTest.php index b9eddfdd..5cfb321c 100644 --- a/tests/Controller/TimesheetControllerTest.php +++ b/tests/Controller/TimesheetControllerTest.php @@ -183,12 +183,23 @@ class TimesheetControllerTest extends ControllerBaseTest $this->assertEquals(1, $timesheet->getProject()->getId()); } - public function testStopAction() + public function testStopActionDoesNotShowRateFieldsForUser() { $client = $this->getClientForAuthenticatedUser(); $this->request($client, '/timesheet/create'); $this->assertTrue($client->getResponse()->isSuccessful()); + $form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form(); + $this->assertFalse($form->has('hourlyRate')); + $this->assertFalse($form->has('fixedRate')); + } + + public function testStopAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/timesheet/create'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form(); $client->submit($form, [ 'timesheet_edit_form' => [ @@ -222,7 +233,7 @@ class TimesheetControllerTest extends ControllerBaseTest public function testCreateActionWithFromAndToValues() { - $client = $this->getClientForAuthenticatedUser(); + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $this->request($client, '/timesheet/create?from=2018-08-02T20%3A00%3A00&to=2018-08-02T20%3A30%3A00'); $this->assertTrue($client->getResponse()->isSuccessful()); @@ -256,7 +267,7 @@ class TimesheetControllerTest extends ControllerBaseTest public function testCreateActionWithBeginAndEndValues() { - $client = $this->getClientForAuthenticatedUser(); + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $this->request($client, '/timesheet/create?begin=2018-08-02&end=2018-08-02'); $this->assertTrue($client->getResponse()->isSuccessful()); diff --git a/tests/KernelTestTrait.php b/tests/KernelTestTrait.php index a2d9d679..78ae603a 100644 --- a/tests/KernelTestTrait.php +++ b/tests/KernelTestTrait.php @@ -34,6 +34,16 @@ trait KernelTestTrait $executor->execute($loader->getFixtures(), true); } + /** + * @param EntityManager $em + * @param string $username + * @return User|null + */ + protected function getUserByName(EntityManager $em, string $username) + { + return $em->getRepository(User::class)->findOneBy(['username' => $username]); + } + /** * @param EntityManager $em * @param string $role @@ -64,6 +74,6 @@ trait KernelTestTrait return null; } - return $em->getRepository(User::class)->findOneBy(['username' => $name]); + return $this->getUserByName($em, $name); } } diff --git a/tests/Voter/AbstractVoterTest.php b/tests/Voter/AbstractVoterTest.php new file mode 100644 index 00000000..4b052387 --- /dev/null +++ b/tests/Voter/AbstractVoterTest.php @@ -0,0 +1,95 @@ +getRoles()); + $accessManager = $this->getMockBuilder(AclDecisionManager::class)->disableOriginalConstructor()->getMock(); + $accessManager->method('isFullyAuthenticated')->willReturn($isAuthenticated); + $accessManager->method('hasRole')->willReturnCallback(function ($role) use ($user) { + return in_array($role, $user->getRoles()); + }); + + $class = new \ReflectionClass($voterClass); + + return $class->newInstance($accessManager, $this->getRolePermissionManager()); + } + + /** + * @param $id + * @param $role + * @return User + */ + protected function getUser($id, $role) + { + $roles = []; + if (!empty($role)) { + $roles[] = $role; + } + + $user = $this->getMockBuilder(User::class)->getMock(); + $user->method('getId')->willReturn($id); + $user->method('getRoles')->willReturn($roles); + + return $user; + } + + /** + * @param array $permissions + * @param bool $overwrite + * @return RolePermissionManager + */ + protected function getRolePermissionManager(array $permissions = [], bool $overwrite = false) + { + if (!$overwrite) { + $activities = ['view_activity', 'edit_activity', 'delete_activity', 'create_activity']; + $projects = ['view_project', 'edit_project', 'delete_project', 'create_project']; + $customers = ['view_customer', 'edit_customer', 'delete_customer', 'create_customer']; + $invoice = ['view_invoice', 'create_invoice']; + $invoiceTemplate = ['view_invoice_template', 'create_invoice_template', 'edit_invoice_template', 'delete_invoice_template']; + $timesheet = ['view_own_timesheet', 'start_own_timesheet', 'stop_own_timesheet', 'create_own_timesheet', 'edit_own_timesheet', 'export_own_timesheet', 'delete_own_timesheet']; + $timesheetOthers = ['view_other_timesheet', 'start_other_timesheet', 'stop_other_timesheet', 'create_other_timesheet', 'edit_other_timesheet', 'delete_other_timesheet']; + $profile = ['view_own_profile', 'edit_own_profile', 'password_own_profile', 'preferences_own_profile', 'api-token_own_profile']; + $profileOther = ['view_other_profile', 'edit_other_profile', 'delete_other_profile', 'password_other_profile', 'roles_other_profile', 'preferences_other_profile', 'api-token_other_profile']; + $user = ['view_user', 'create_user', 'delete_user']; + $rate = ['view_rate_own_timesheet', 'edit_rate_own_timesheet']; + $rateOther = ['view_rate_other_timesheet', 'edit_rate_other_timesheet']; + + $roleUser = []; + $roleTeamlead = ['view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile']; + $roleAdmin = ['hourly-rate_own_profile']; + $roleSuperAdmin = ['hourly-rate_own_profile', 'hourly-rate_other_profile', 'delete_own_profile', 'roles_own_profile']; + + $permissions = [ + 'ROLE_USER' => array_merge($timesheet, $profile, $roleUser), + 'ROLE_TEAMLEAD' => array_merge($invoice, $timesheet, $timesheetOthers, $profile, $roleTeamlead), + 'ROLE_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $rate, $rateOther, $roleAdmin), + 'ROLE_SUPER_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $profileOther, $user, $rate, $rateOther, $roleSuperAdmin), + ]; + } + + return new RolePermissionManager($permissions); + } +} diff --git a/tests/Voter/ActivityVoterTest.php b/tests/Voter/ActivityVoterTest.php new file mode 100644 index 00000000..d1e493e9 --- /dev/null +++ b/tests/Voter/ActivityVoterTest.php @@ -0,0 +1,66 @@ +getRoles()); + $sut = $this->getVoter(ActivityVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $result = VoterInterface::ACCESS_GRANTED; + foreach ([$user3, $user4] as $user) { + yield [$user, new Activity(), 'view', $result]; + yield [$user, new Activity(), 'edit', $result]; + yield [$user, new Activity(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Activity(), 'view', $result]; + yield [$user, new Activity(), 'edit', $result]; + yield [$user, new Activity(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Activity(), 'view_activity', $result]; + yield [$user, new Activity(), 'edit_activity', $result]; + yield [$user, new Activity(), 'delete_activity', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, $user, 'delete', $result]; + } + } +} diff --git a/tests/Voter/CustomerVoterTest.php b/tests/Voter/CustomerVoterTest.php new file mode 100644 index 00000000..16d846a8 --- /dev/null +++ b/tests/Voter/CustomerVoterTest.php @@ -0,0 +1,66 @@ +getRoles()); + $sut = $this->getVoter(CustomerVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $result = VoterInterface::ACCESS_GRANTED; + foreach ([$user3, $user4] as $user) { + yield [$user, new Customer(), 'view', $result]; + yield [$user, new Customer(), 'edit', $result]; + yield [$user, new Customer(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Customer(), 'view', $result]; + yield [$user, new Customer(), 'edit', $result]; + yield [$user, new Customer(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Customer(), 'view_customer', $result]; + yield [$user, new Customer(), 'edit_customer', $result]; + yield [$user, new Customer(), 'delete_customer', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, $user, 'delete', $result]; + } + } +} diff --git a/tests/Voter/InvoiceTemplateVoterTest.php b/tests/Voter/InvoiceTemplateVoterTest.php new file mode 100644 index 00000000..35463906 --- /dev/null +++ b/tests/Voter/InvoiceTemplateVoterTest.php @@ -0,0 +1,66 @@ +getRoles()); + $sut = $this->getVoter(InvoiceTemplateVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $result = VoterInterface::ACCESS_GRANTED; + foreach ([$user3, $user4] as $user) { + yield [$user, new InvoiceTemplate(), 'view', $result]; + yield [$user, new InvoiceTemplate(), 'edit', $result]; + yield [$user, new InvoiceTemplate(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new InvoiceTemplate(), 'view', $result]; + yield [$user, new InvoiceTemplate(), 'edit', $result]; + yield [$user, new InvoiceTemplate(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new InvoiceTemplate(), 'view_invoice_template', $result]; + yield [$user, new InvoiceTemplate(), 'edit_invoice_template', $result]; + yield [$user, new InvoiceTemplate(), 'delete_invoice_template', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, $user, 'delete', $result]; + } + } +} diff --git a/tests/Voter/InvoiceVoterTest.php b/tests/Voter/InvoiceVoterTest.php deleted file mode 100644 index 9c75fa8f..00000000 --- a/tests/Voter/InvoiceVoterTest.php +++ /dev/null @@ -1,80 +0,0 @@ -getRoles()); - - $accessManager = $this->getMockBuilder(AclDecisionManager::class)->disableOriginalConstructor()->getMock(); - $accessManager->method('isFullyAuthenticated')->willReturn($isAuthenticated); - $accessManager->method('hasRole')->willReturn($hasRole); - - $sut = new InvoiceVoter($accessManager); - - $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); - } - - public function getTestData() - { - $user0 = $this->getUser(0, User::ROLE_CUSTOMER); - $user1 = $this->getUser(1, User::ROLE_USER); - $user2 = $this->getUser(1, User::ROLE_TEAMLEAD); - $user3 = $this->getUser(1, User::ROLE_ADMIN); - $user4 = $this->getUser(1, User::ROLE_SUPER_ADMIN); - - $users = [$user0, $user1, $user2, $user3, $user4]; - $attributes = [InvoiceVoter::VIEW, InvoiceVoter::CREATE, InvoiceVoter::EDIT, InvoiceVoter::DELETE]; - $subjects = ['invoice', 'invoice_template', new InvoiceTemplate()]; - - foreach ($attributes as $attribute) { - foreach ($users as $user) { - foreach ($subjects as $subject) { - yield [$user, false, false, $subject, $attribute, VoterInterface::ACCESS_DENIED]; - yield [$user, true, false, $subject, $attribute, VoterInterface::ACCESS_DENIED]; - yield [$user, false, true, $subject, $attribute, VoterInterface::ACCESS_DENIED]; - yield [$user, true, true, $subject, $attribute, VoterInterface::ACCESS_GRANTED]; - yield [$user, true, true, $subject, 'something', VoterInterface::ACCESS_ABSTAIN]; - } - yield [$user, true, true, new \stdClass(), $attribute, VoterInterface::ACCESS_ABSTAIN]; - yield [$user, false, true, new \stdClass(), $attribute, VoterInterface::ACCESS_ABSTAIN]; - yield [$user, true, false, new \stdClass(), $attribute, VoterInterface::ACCESS_ABSTAIN]; - yield [$user, false, false, new \stdClass(), $attribute, VoterInterface::ACCESS_ABSTAIN]; - yield [$user, true, true, null, $attribute, VoterInterface::ACCESS_ABSTAIN]; - yield [$user, true, true, 'foo', $attribute, VoterInterface::ACCESS_ABSTAIN]; - } - } - } - - protected function getUser($id, $role) - { - $user = $this->getMockBuilder(User::class)->getMock(); - $user->method('getId')->willReturn($id); - $user->method('getRoles')->willReturn([$role]); - - return $user; - } -} diff --git a/tests/Voter/ProjectVoterTest.php b/tests/Voter/ProjectVoterTest.php new file mode 100644 index 00000000..4f036ece --- /dev/null +++ b/tests/Voter/ProjectVoterTest.php @@ -0,0 +1,67 @@ +getRoles()); + $sut = $this->getVoter(ProjectVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $result = VoterInterface::ACCESS_GRANTED; + foreach ([$user3, $user4] as $user) { + yield [$user, new Project(), 'view', $result]; + yield [$user, new Project(), 'edit', $result]; + yield [$user, new Project(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Project(), 'view', $result]; + yield [$user, new Project(), 'edit', $result]; + yield [$user, new Project(), 'delete', $result]; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new Project(), 'create_project', $result]; + yield [$user, new Project(), 'view_project', $result]; + yield [$user, new Project(), 'edit_project', $result]; + yield [$user, new Project(), 'delete_project', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, $user, 'delete', $result]; + } + } +} diff --git a/tests/Voter/RolePermissionVoterTest.php b/tests/Voter/RolePermissionVoterTest.php new file mode 100644 index 00000000..7a841359 --- /dev/null +++ b/tests/Voter/RolePermissionVoterTest.php @@ -0,0 +1,133 @@ +getRoles()); + $sut = $this->getVoter(RolePermissionVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $invoice = [ + 'create_invoice_template' => null, + 'view_invoice' => null, + 'create_invoice' => null, + ]; + + $timesheet = [ + 'view_own_timesheet' => null, + 'create_own_timesheet' => null, + 'export_own_timesheet' => null, + ]; + + $timesheetOther = [ + 'view_other_timesheet' => null, + 'create_other_timesheet' => null, + ]; + + $others = [ + 'create_activity' => null, + 'create_customer' => null, + 'create_project' => null, + ]; + + $users = [ + 'create_user' => null, + 'view_user' => null, + ]; + + // ================== GRANTED ================== + $result = VoterInterface::ACCESS_GRANTED; + + $entries = array_merge($timesheet); + foreach ([$user1, $user2, $user3, $user4] as $user) { + foreach ($entries as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + yield [$user, null, $permission, $result]; + } + } + + $entriesAdmin = array_merge($others, $timesheetOther, $invoice, $timesheet); + foreach ([$user3, $user4] as $user) { + foreach ($entriesAdmin as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + yield [$user, null, $permission, $result]; + } + } + + $entriesSuperAdmin = array_merge($users); + foreach ($entriesSuperAdmin as $permission => $entity) { + yield [$user4, $entity, $permission, $result]; + yield [$user4, null, $permission, $result]; + } + + // ================== DENIED ================== + // this test might fail in the future due to the role permissions + $result = VoterInterface::ACCESS_DENIED; + + foreach ([$user0, $user1, $user2] as $user) { + foreach ($others as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + } + } + foreach ([$user0, $user1] as $user) { + foreach ($invoice as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + } + } + foreach ([$user0] as $user) { + foreach ($timesheet as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + } + } + foreach ([$user0, $user1] as $user) { + foreach ($timesheetOther as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + } + } + + // ================== ABSTAIN ================== + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user3, $user4] as $user) { + yield [$user, new Activity(), 'view', $result]; + yield [$user, new Activity(), 'edit', $result]; + yield [$user, new Activity(), 'delete', $result]; + yield [$user, new Activity(), 'ROLE_USER', $result]; + yield [$user, new Activity(), 'ROLE_ADMIN', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, $user, 'delete', $result]; + } + } +} diff --git a/tests/Voter/TimesheetVoterTest.php b/tests/Voter/TimesheetVoterTest.php index c78b61c5..3274b97c 100644 --- a/tests/Voter/TimesheetVoterTest.php +++ b/tests/Voter/TimesheetVoterTest.php @@ -11,51 +11,69 @@ namespace App\Tests\Voter; use App\Entity\Activity; use App\Entity\Customer; +use App\Entity\Project; use App\Entity\Timesheet; use App\Entity\User; -use App\Security\AclDecisionManager; use App\Voter\TimesheetVoter; -use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** * @covers \App\Voter\TimesheetVoter */ -class TimesheetVoterTest extends TestCase +class TimesheetVoterTest extends AbstractVoterTest { /** * @dataProvider getTestData */ - public function testVote($user, $roles, $allow, $subject, $attributes, $result) + public function testVote(User $user, $subject, $attribute, $result) { - $token = new UsernamePasswordToken($user, 'foo', 'bar', $roles); + $token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles()); + $sut = $this->getVoter(TimesheetVoter::class, $user); - $accessManager = $this->getMockBuilder(AclDecisionManager::class)->disableOriginalConstructor()->getMock(); - $accessManager->method('isFullyAuthenticated')->willReturn($allow); - $accessManager->method('hasRole')->willReturn($allow); - - $sut = new TimesheetVoter($accessManager); - - $this->assertEquals($result, $sut->vote($token, $subject, $attributes)); + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); } public function getTestData() { - $user0 = $this->getUser(0, User::ROLE_CUSTOMER); + $user0 = $this->getUser(0, null); $user1 = $this->getUser(1, User::ROLE_USER); $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); - return [ - [$user0, $user0->getRoles(), false, new Customer(), [TimesheetVoter::EDIT], VoterInterface::ACCESS_ABSTAIN], - [$user1, $user1->getRoles(), false, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED], - [$user1, $user1->getRoles(), false, $this->getTimesheet($user0), [TimesheetVoter::EDIT], VoterInterface::ACCESS_DENIED], - [$user2, $user2->getRoles(), true, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED], - ['foo', [], false, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_DENIED], - [$user2, $user2->getRoles(), true, new Activity(), [TimesheetVoter::EDIT], VoterInterface::ACCESS_ABSTAIN], - [$user2, $user2->getRoles(), true, $this->getTimesheet($user2), [TimesheetVoter::VIEW], VoterInterface::ACCESS_GRANTED], - [$user1, $user1->getRoles(), false, $this->getTimesheet($user2), [TimesheetVoter::VIEW], VoterInterface::ACCESS_DENIED], + $timesheet1 = $this->getTimesheet($user1); + $timesheet2 = $this->getTimesheet($user2); + $timesheet3 = $this->getTimesheet($user3); + $timesheet4 = $this->getTimesheet($user4); + + $result = VoterInterface::ACCESS_GRANTED; + $times = [ + [$user1, $timesheet1], + [$user2, $timesheet2], + [$user3, $timesheet3], + [$user4, $timesheet4], + [$user2, $timesheet1], + [$user3, $timesheet2], + [$user4, $timesheet3], ]; + foreach ($times as $timeEntry) { + yield [$timeEntry[0], $timeEntry[1], 'start', $result]; + yield [$timeEntry[0], $timeEntry[1], 'stop', $result]; + yield [$timeEntry[0], $timeEntry[1], 'edit', $result]; + yield [$timeEntry[0], $timeEntry[1], 'delete', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + $times = [ + [$user1, $timesheet4], + ]; + foreach ($times as $timeEntry) { + yield [$timeEntry[0], $timeEntry[1], 'start', $result]; + yield [$timeEntry[0], $timeEntry[1], 'stop', $result]; + yield [$timeEntry[0], $timeEntry[1], 'edit', $result]; + yield [$timeEntry[0], $timeEntry[1], 'delete', $result]; + } } protected function getTimesheet($user) @@ -63,6 +81,14 @@ class TimesheetVoterTest extends TestCase $timesheet = new Timesheet(); $timesheet->setUser($user); + $activity = new Activity(); + $project = new Project(); + $activity->setProject($project); + $timesheet->setProject($project); + $timesheet->setActivity($activity); + $customer = new Customer(); + $project->setCustomer($customer); + return $timesheet; } diff --git a/tests/Voter/UserVoterTest.php b/tests/Voter/UserVoterTest.php new file mode 100644 index 00000000..438195be --- /dev/null +++ b/tests/Voter/UserVoterTest.php @@ -0,0 +1,87 @@ +getRoles()); + $sut = $this->getVoter(UserVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $result = VoterInterface::ACCESS_GRANTED; + foreach ([$user1, $user2, $user3] as $user) { + yield [$user4, $user, 'view', $result]; + yield [$user4, $user, 'edit', $result]; + yield [$user4, $user, 'password', $result]; + yield [$user4, $user, 'roles', $result]; + yield [$user4, $user, 'preferences', $result]; + yield [$user4, $user, 'api-token', $result]; + } + + foreach ([$user1, $user2, $user3] as $user) { + yield [$user4, $user, 'delete', $result]; + } + + foreach ([$user1, $user2, $user3, $user4] as $user) { + yield [$user, $user, 'view', $result]; + yield [$user, $user, 'edit', $result]; + yield [$user, $user, 'password', $result]; + yield [$user, $user, 'preferences', $result]; + yield [$user, $user, 'api-token', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, $user, 'roles', $result]; + yield [$user3, $user, 'roles', $result]; + yield [$user, $user3, 'view', $result]; + yield [$user, $user3, 'edit', $result]; + yield [$user, $user3, 'delete', $result]; + yield [$user, $user4, 'view', $result]; + yield [$user, $user4, 'edit', $result]; + yield [$user, $user4, 'delete', $result]; + yield [$user, $user4, 'hourly-rate', $result]; + } + + $result = VoterInterface::ACCESS_ABSTAIN; + foreach ([$user0, $user1, $user2] as $user) { + yield [$user, new InvoiceTemplate(), 'view', $result]; + yield [$user, new InvoiceTemplate(), 'edit', $result]; + yield [$user, new InvoiceTemplate(), 'delete', $result]; + yield [$user, new \stdClass(), 'view', $result]; + yield [$user, null, 'edit', $result]; + yield [$user, null, 'delete', $result]; + } + } +} diff --git a/var/docs/README.md b/var/docs/README.md index 88d965cc..0418480e 100644 --- a/var/docs/README.md +++ b/var/docs/README.md @@ -14,7 +14,8 @@ For the most parts Kimai usage should be self-explanatory, so we will only cover - [Configurations](configurations.md) - intro into the global application configs - [Calendar](calendar.md) - the Timesheet calendar view - [Dashboard & widgets](dashboard.md) - how to configure widgets and dashboard sections - - [Emails](emails.md) - transport configuration and handling of emails + - [Emails](emails.md) - transport configuration and handling of emails + - [Permissions](permissions.md) - flexible and configurable ROLE based permission system - [Developer](developers.md) - docs about extending Kimai's feature set - [API](developers_api.md) - how to use the JSON API - [Theme settings](theme.md) - theme related settings diff --git a/var/docs/permissions.md b/var/docs/permissions.md new file mode 100644 index 00000000..4a5123ff --- /dev/null +++ b/var/docs/permissions.md @@ -0,0 +1,115 @@ +# Permissions + +Kimai 2 provides a flexible permissions system, which can be adapted though your [local.yaml](configurations.md) config +file and that is based on [user roles](users.md). + +## Understanding permission structure + +Before you learn to configure the permission system, you have to understand the three involved config types: + +1. `Permission sets` define a re-usable name for a list of "permission names" +2. `Permission maps` will apply a list of "permission sets" to a "user role" +3. `Permissions` apply a list of "permission names" to a "user role" + +An example and its explanation: + +```yaml + permissions: + sets: + ACTIVITY: [view_activity,create_activity] + TIMESHEET: [view_own_timesheet,start_own_timesheet] + maps: + ROLE_USER: [TIMESHEET] + ROLE_ADMIN: [TIMESHEET,ACTIVITY] + roles: + ROLE_USER: [my_profile] + ROLE_ADMIN: [my_profile,start_other_timesheet] +``` + +In `sets` we define the two `permissions sets` names "ACTIVITY" and "TIMESHEET". In `maps` we apply the `permissions set` to the +called "TIMESHEET" to the user-role "ROLE_USER" and the two `permissions set` called "TIMESHEET" and "ACTIVITY" to the user-role "ROLE_ADMIN". + +At this step the role have the following permissions: + +- `ROLE_USER`: view_own_timesheet,start_own_timesheet +- `ROLE_ADMIN`: view_own_timesheet,start_own_timesheet,view_activity,create_activity + +As last step, the list of `permission names` will be added to the list of calculated permissions. +So we add the permission "my_profile" to the user-role "ROLE_USER" and the two permissions "my_profile" and "start_other_timesheet" to the user-role "ROLE_ADMIN". + +At the end the system calculated the final list of permissions: + +- `ROLE_USER`: view_own_timesheet,start_own_timesheet,my_profile +- `ROLE_ADMIN`: view_own_timesheet,start_own_timesheet,view_activity,create_activity,my_profile,start_other_timesheet + +## Existing permissions + +The permission-names were chosen to be self-explanatory. In the hope that it worked, here is the full list of existing permissions: + +| Permission name | Set name | API use | Description | +|---|---|---|---| +| view_activity | ACTIVITIES | | allows access to the activity administration | +| create_activity | ACTIVITIES | | - | +| edit_activity | ACTIVITIES | | - | +| delete_activity | ACTIVITIES | | - | +| view_project | PROJECTS | | allows access to the project administration | +| create_project | PROJECTS | | - | +| edit_project | PROJECTS | | - | +| delete_project | PROJECTS | | - | +| view_customer | CUSTOMERS | | allows access to the customer administration | +| create_customer | CUSTOMERS | | - | +| edit_customer | CUSTOMERS | | - | +| delete_customer | CUSTOMERS | | - | +| view_invoice | INVOICE | | allows access to the invoice section | +| create_invoice | INVOICE | | - | +| view_invoice_template | INVOICE_TEMPLATE | | allows access to the invoice and invoice template section | +| create_invoice_template | INVOICE_TEMPLATE | | - | +| edit_invoice_template | INVOICE_TEMPLATE | | - | +| delete_invoice_template | INVOICE_TEMPLATE | | - | +| view_own_timesheet | TIMESHEET | X | - | +| start_own_timesheet | TIMESHEET | | - | +| stop_own_timesheet | TIMESHEET | | - | +| create_own_timesheet | TIMESHEET | X | - | +| edit_own_timesheet | TIMESHEET | | - | +| export_own_timesheet | TIMESHEET | | - | +| delete_own_timesheet | TIMESHEET | | - | +| view_other_timesheet | TIMESHEET_OTHER | | allows access to the complete timesheet view | +| start_other_timesheet | TIMESHEET_OTHER | | - | +| stop_other_timesheet | TIMESHEET_OTHER | | - | +| create_other_timesheet | TIMESHEET_OTHER | | - | +| edit_other_timesheet | TIMESHEET_OTHER | | - | +| delete_other_timesheet | TIMESHEET_OTHER | | - | +| view_rate_own_timesheet | RATE | | - | +| edit_rate_own_timesheet | RATE | | - | +| view_rate_other_timesheet | RATE_OTHER | | - | +| edit_rate_other_timesheet | RATE_OTHER | | - | +| view_own_profile | PROFILE | | Allows access to the own profile view. Without this permission, users cannot access any of their profile settings or passwords ... | +| edit_own_profile | PROFILE | | - | +| delete_own_profile | PROFILE | | - | +| password_own_profile | PROFILE | | - | +| roles_own_profile | PROFILE | | - | +| preferences_own_profile | PROFILE | | - | +| api-token_own_profile | PROFILE | | - | +| view_other_profile | PROFILE_OTHER | | - | +| edit_other_profile | PROFILE_OTHER | | - | +| delete_other_profile | PROFILE_OTHER | | - | +| password_other_profile | PROFILE_OTHER | | allows to change the password for another user | +| roles_other_profile | PROFILE_OTHER | | allows to change roles for other users | +| preferences_other_profile | PROFILE_OTHER | | allows to change the preferences for another user | +| api-token_other_profile | PROFILE_OTHER | | allows to set the API login token for other users | +| hourly-rate_own_profile | - | | allows to edit the own user specific hourly rate |  +| hourly-rate_other_profile | - | | allows to edit other users specific hourly rate |  +| view_user | USER | X | allows to access the User administration and see the list of all users | +| create_user | USER | | allows to create new users | +| delete_user | USER | | allows to delete existing users | + +## Configure permissions + +Knowing that many companies need a different combination of allowed permissions than the default ones, you might also +want to change the pre-configured permission. + +You can do that in your [local.yaml](configurations.md). Define the permissions like we did in the above mentioned example, +you might start by copying the default permissions from `kimai.yaml`. + +Be aware: if you configure your own permission definition, you have to overwrite the complete +node (`sets`, `maps` or`roles`) that you edited and define all SETS and/or ROLES. diff --git a/var/docs/users.md b/var/docs/users.md index 10b9adde..5b0d56c3 100644 --- a/var/docs/users.md +++ b/var/docs/users.md @@ -1,16 +1,20 @@ # Users -## Roles & Permissions +## Roles -There are multiple pre-defined roles in Kimai, which define the ACLs. A user can only inherit one role, where the roles extend each user. +There are multiple pre-defined roles in Kimai, which define the ACLs/permissions. -| Role name | extends | Gives permission for | +| Role name | extends | Description | |---|---|---| -| ROLE_CUSTOMER | - | Currently has no permissions, but was reserved for future functionality | -| ROLE_USER | ROLE_CUSTOMER | Time-tracking | -| ROLE_TEAMLEAD | ROLE_USER | All of the above, plus: editing other users timesheets | -| ROLE_ADMIN | ROLE_TEAMLEAD | All of the above, plus: editing customers, editing projects, editing activities | -| ROLE_SUPER_ADMIN | ROLE_ADMIN | All of the above, plus: editing users | +| ROLE_CUSTOMER | - | Currently not used, reserved for future features | +| ROLE_USER | ROLE_CUSTOMER | Normal user that wants to track working times | +| ROLE_TEAMLEAD | ROLE_USER | This role manages teams of ROLE_USER (this feature is not yet implemented, but planned for the future) and has further permissions on invoices | +| ROLE_ADMIN | ROLE_TEAMLEAD | Admins can do almost everything in Kimai, except some user specific tasks | +| ROLE_SUPER_ADMIN | ROLE_ADMIN | Evey Super-Admin can do anything Kimai | + +### Permissions + +The permission system is configurable through a configuration file. You can find further information in the [permissions](permissions.md) chapter. ## Login