added configurable permission system (#424)

This commit is contained in:
Kevin Papst
2018-11-26 13:20:32 +01:00
committed by GitHub
parent 0334f6ce86
commit 8fddf627bf
62 changed files with 1831 additions and 794 deletions

View File

@@ -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

View File

@@ -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 }

View File

@@ -118,6 +118,13 @@ services:
App\Timesheet\Calculator\RateCalculator:
arguments: ["%kimai.timesheet.rates%"]
# ================================================================================
# SECURITY & VOTER
# ================================================================================
App\Security\RolePermissionManager:
arguments: ["%kimai.permissions%"]
# ================================================================================
# REPOSITORIES
# ================================================================================

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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)")
*

View File

@@ -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

View File

@@ -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)")
*

View File

@@ -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,
]);
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),
]);
}

View File

@@ -47,7 +47,6 @@ class UserFixtures extends Fixture
private $encoder;
/**
* AppFixtures constructor.
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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.
*

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\User;
use Symfony\Component\EventDispatcher\Event;
/**
* This event should be used, if a user profile is loaded and we full data including dynamic user preferences
*/
class PrepareUserEvent extends Event
{
public const PREPARE = 'app.prepare_user';
/**
* @var User
*/
protected $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
}

View File

@@ -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()

View File

@@ -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')
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\EventSubscriber;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class UserProfileSubscriber implements EventSubscriberInterface
{
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var TokenStorageInterface
*/
protected $storage;
/**
* @param EventDispatcherInterface $dispatcher
* @param TokenStorageInterface $storage
*/
public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage)
{
$this->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);
}
}

View File

@@ -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',
]);
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Security;
class RolePermissionManager
{
/**
* @var array
*/
protected $permissions = [];
/**
* @var array
*/
protected $knownPermissions = [];
/**
* @param array $permissions
*/
public function __construct(array $permissions)
{
$this->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]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,72 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check permissions on InvoiceTemplateVote.
*/
class InvoiceTemplateVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: InvoiceTemplate)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
self::DELETE
];
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!$subject instanceof InvoiceTemplate) {
return false;
}
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
}
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($subject instanceof InvoiceTemplate) {
return $this->hasRolePermission($user, $attribute . '_invoice_template');
}
return false;
}
}

View File

@@ -1,136 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check permissions on Invoices.
*/
class InvoiceVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const CREATE = 'create';
public const DELETE = 'delete';
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::EDIT,
self::DELETE
];
public const ALLOWED_SUBJECTS = [
'invoice',
'invoice_template'
];
/**
* @param string $attribute
* @param mixed $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
if (!$subject instanceof InvoiceTemplate) {
if (!is_string($subject) || !in_array($subject, self::ALLOWED_SUBJECTS)) {
return false;
}
}
return true;
}
/**
* @param string $attribute
* @param string|InvoiceTemplate $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Voter;
use App\Entity\Activity;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check the free-configurable permission from "kimai.permissions".
*/
class RolePermissionVoter extends AbstractVoter
{
/**
* @param string $attribute
* @param mixed $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
// we only work on single strings that have no subject
if (null !== $subject) {
return false;
}
// and which is not neither a user role like USER_ADMIN
// nor an implicit role like IS_REMEMBERED / IS_FULLY_AUTHENTICATED
if (strpos($attribute, 'ROLE_') === false && strpos($attribute, 'IS_') === false) {
return $this->isRegisteredPermission($attribute);
}
return false;
}
/**
* @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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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) }}

View File

@@ -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) }}

View File

@@ -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) }}

View File

@@ -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 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% else %}
{% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">&dash;</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end or not is_granted('view_rate', entry) %}
&dash;
{% else %}
{{ entry.rate|money(entry.project.customer.currency) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
<a href="{{ path('admin_customer_edit', {'id': entry.project.customer.id}) }}">{{ widgets.label_customer(entry.project.customer) }}</a>
</td>

View File

@@ -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) }}

View File

@@ -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') }}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" id="print-invoice-button" class="btn btn-success pull-right">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
{% if is_granted('create_invoice') %}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" id="print-invoice-button" class="btn btn-success pull-right">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% if is_granted('create_invoice') %}
<script type="text/javascript">
$(document).ready(function () {
$( "#print-invoice-button" ).click(function() {
@@ -48,4 +59,5 @@
});
});
</script>
{% endif %}
{% endblock %}

View File

@@ -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 @@
<td class="hidden-xs hidden-sm">{{ entry.dueDays }}</td>
<td class="hidden-xs hidden-sm">{{ entry.vat }}</td>
<td>
{% 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) }}
</td>
</tr>

View File

@@ -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 %}
<ul class="control-sidebar-menu">
<li>
<a href="{{ path('user_profile', {'username' : user.username}) }}">
<a href="{{ linkUserProfile }}">
<div class="pull-left image">
{{ macro.avatar(user.avatar, user.username, 'img-circle') }}
</div>
@@ -17,6 +21,7 @@
</ul>
<ul class="control-sidebar-menu">
{% if is_granted('edit_own_profile') %}
<li>
<a href="{{ path('user_profile_edit', {'username' : user.username}) }}">
<div class="pull-left image">
@@ -27,8 +32,10 @@
</div>
</a>
</li>
{% endif %}
{% if is_granted('preferences_own_profile') %}
<li>
<a href="{{ path('user_profile_preferences', {'username' : user.username}) }}">
<a href="{{ path('user_profile_preferences', {'username' : user.username}) }}">
<div class="pull-left image">
<i class="{{ 'settings'|icon }} text-gray"></i>
</div>
@@ -37,6 +44,7 @@
</div>
</a>
</li>
{% endif %}
<li>
<a href="{{ path('help') }}">
<div class="pull-left image">

View File

@@ -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 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% if is_granted('view_rate', entry) %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% endif %}
{% else %}
{% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">&dash;</td>
{% if is_granted('view_rate', entry) %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">&dash;</td>
{% endif %}
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.project.customer) }}</td>
@@ -64,7 +83,10 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">{{ entry.description|desc2html }}</td>
<td>
{% 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) }}
</td>
</tr>

View File

@@ -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());

View File

@@ -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();

View File

@@ -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));

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -0,0 +1,95 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\User;
use App\Security\AclDecisionManager;
use App\Security\RolePermissionManager;
use App\Voter\AbstractVoter;
use PHPUnit\Framework\TestCase;
abstract class AbstractVoterTest extends TestCase
{
/**
* @param string $voterClass
* @param User $user
* @return AbstractVoter
* @throws \ReflectionException
*/
protected function getVoter(string $voterClass, User $user)
{
$isAuthenticated = empty($user->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);
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Activity;
use App\Entity\User;
use App\Voter\ActivityVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\ActivityVoter
*/
class ActivityVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Customer;
use App\Entity\User;
use App\Voter\CustomerVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\CustomerVoter
*/
class CustomerVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Voter\InvoiceTemplateVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\InvoiceTemplateVoter
*/
class InvoiceTemplateVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -1,80 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Security\AclDecisionManager;
use App\Voter\InvoiceVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\InvoiceVoter
*/
class InvoiceVoterTest extends TestCase
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $isAuthenticated, $hasRole, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Project;
use App\Entity\User;
use App\Voter\ProjectVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\ProjectVoter
*/
class ProjectVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -0,0 +1,133 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Activity;
use App\Entity\User;
use App\Voter\RolePermissionVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\RolePermissionVoter
*/
class RolePermissionVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,87 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Voter\UserVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\UserVoter
*/
class UserVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->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];
}
}
}

View File

@@ -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

115
var/docs/permissions.md Normal file
View File

@@ -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.

View File

@@ -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