commit bc18212984364980013862cc5515a125ccff6e6d
Author: Moritz Bunkus <m.bunkus@linet.de>
Date:   Wed Feb 13 17:35:31 2019 +0100

    Berechtigung, Rechnungen persönlich zugeordneter Projekte einzusehen
    
    Man kann nun Mitarbeiter*innen zu Projekten zuordnen, indem man sie in
    den Projektstammdaten hinzufügt.
    
    Ist eine Mitarbeiter*in zu einem Projekt zugeordnet, so darf sie alle
    Rechnungen ansehen, die über die Projektnummer der Rechnung (nicht der
    Positionen) dem Projekt zugeordnet sind, auch dann, wenn sie nicht das
    allgemeine Recht zum Erstellen und Ansehen von Rechnungen hat.
    
    Verändern oder Ausdrucken der Rechnungen ist nicht gestattet.
    
    Die Verwaltung dieser Projektberechtigungen ist über ein neues
    Gruppenrecht eingeschränkt.

diff --git a/SL/AR.pm b/SL/AR.pm
index 0c8f842b3..2082a4eea 100644
--- a/SL/AR.pm
+++ b/SL/AR.pm
@@ -516,9 +516,44 @@ sub ar_transactions {
 
   my $where = "1 = 1";
 
-  unless ( $::auth->assert('show_ar_transactions', 1) ) {
-    $where .= " AND NOT invoice = 'f' ";  # remove ar transactions from Sales -> Reports -> Invoices
-  };
+  # Permissions:
+  # - Always return invoices & AR transactions for projects the employee has "view invoices" permissions for, no matter what the other rules say.
+  # - Exclude AR transactions if no permissions for them exist.
+  # - Limit to own invoices unless may edit all invoices.
+  # - If may edit all, allow filtering by employee/salesman.
+  my (@permission_where, @permission_values);
+
+  if (!$::auth->assert('show_ar_transactions', 1) ) {
+    push @permission_where, "NOT invoice = 'f'";  # remove ar transactions from Sales -> Reports -> Invoices
+  }
+
+  if (!$::auth->assert('sales_all_edit', 1)) {
+    # only show own invoices
+    push @permission_where,  "a.employee_id = ?";
+    push @permission_values, SL::DB::Manager::Employee->current->id;
+
+  } else {
+    if ($form->{employee_id}) {
+      push @permission_where,  "a.employee_id = ?";
+      push @permission_values, conv_i($form->{employee_id});
+    }
+    if ($form->{salesman_id}) {
+      push @permission_where,  "a.salesman_id = ?";
+      push @permission_values, conv_i($form->{salesman_id});
+    }
+  }
+
+  if (@permission_where) {
+    my $permission_where_str = "OR (" . join(" AND ", map { "($_)" } @permission_where) . ")";
+    $where .= qq|
+      AND (   (a.globalproject_id IN (
+               SELECT epi.project_id
+               FROM employee_project_invoices epi
+               WHERE epi.employee_id = ?))
+           $permission_where_str)
+    |;
+    push @values, SL::DB::Manager::Employee->current->id, @permission_values;
+  }
 
   if ($form->{customer}) {
     $where .= " AND c.name ILIKE ?";
@@ -578,21 +613,6 @@ sub ar_transactions {
     }
   }
 
-  if (!$main::auth->assert('sales_all_edit', 1)) {
-    # only show own invoices
-    $where .= " AND a.employee_id = (select id from employee where login= ?)";
-    push (@values, $::myconfig{login});
-  } else {
-    if ($form->{employee_id}) {
-      $where .= " AND a.employee_id = ?";
-      push @values, conv_i($form->{employee_id});
-    }
-    if ($form->{salesman_id}) {
-      $where .= " AND a.salesman_id = ?";
-      push @values, conv_i($form->{salesman_id});
-    }
-  };
-
   if ($form->{parts_partnumber}) {
     $where .= <<SQL;
       AND EXISTS (
diff --git a/SL/Controller/Project.pm b/SL/Controller/Project.pm
index 601b245b7..11cdd9c47 100644
--- a/SL/Controller/Project.pm
+++ b/SL/Controller/Project.pm
@@ -12,6 +12,7 @@ use SL::Controller::Helper::ReportGenerator;
 use SL::CVar;
 use SL::DB::Customer;
 use SL::DB::DeliveryOrder;
+use SL::DB::Employee;
 use SL::DB::Invoice;
 use SL::DB::Order;
 use SL::DB::Project;
@@ -29,11 +30,12 @@ use Rose::DB::Object::Helpers qw(as_tree);
 use Rose::Object::MakeMethods::Generic
 (
  scalar => [ qw(project) ],
- 'scalar --get_set_init' => [ qw(models customers project_types project_statuses projects linked_records) ],
+ 'scalar --get_set_init' => [ qw(models customers project_types project_statuses projects linked_records employees may_edit_invoice_permissions) ],
 );
 
 __PACKAGE__->run_before('check_auth',   except => [ qw(ajax_autocomplete) ]);
 __PACKAGE__->run_before('load_project', only   => [ qw(edit update destroy) ]);
+__PACKAGE__->run_before('use_multiselect_js', only => [ qw(new create edit update) ]);
 
 #
 # actions
@@ -166,6 +168,8 @@ sub check_auth {
 
 sub init_project_statuses { SL::DB::Manager::ProjectStatus->get_all_sorted }
 sub init_project_types    { SL::DB::Manager::ProjectType->get_all_sorted   }
+sub init_employees        { SL::DB::Manager::Employee->get_all_sorted   }
+sub init_may_edit_invoice_permissions { $::auth->assert('project_edit_view_invoices_permission', 1) }
 
 sub init_linked_records {
   my ($self) = @_;
@@ -223,6 +227,10 @@ sub init_customers {
   return SL::DB::Manager::Customer->get_all_sorted(where => [ or => [ obsolete => 0, obsolete => undef, @customer_id ]]);
 }
 
+sub use_multiselect_js {
+  $::request->layout->use_javascript("${_}.js") for qw(jquery.selectboxes jquery.multiselect2side);
+}
+
 sub display_form {
   my ($self, %params) = @_;
 
@@ -246,6 +254,12 @@ sub create_or_update {
   my $is_new = !$self->project->id;
   my $params = delete($::form->{project}) || { };
 
+  if (!$self->may_edit_invoice_permissions) {
+    delete $params->{employee_invoice_permissions};
+  } elsif (!$params->{employee_invoice_permissions}) {
+    $params->{employee_invoice_permissions} = [];
+  }
+
   delete $params->{id};
   $self->project->assign_attributes(%{ $params });
 
diff --git a/SL/DB/Employee.pm b/SL/DB/Employee.pm
index 4cc109a68..8da20c0b9 100644
--- a/SL/DB/Employee.pm
+++ b/SL/DB/Employee.pm
@@ -5,6 +5,13 @@ use strict;
 use SL::DB::MetaSetup::Employee;
 use SL::DB::Manager::Employee;
 
+__PACKAGE__->meta->add_relationship(
+  project_invoice_permissions  => {
+    type       => 'many to many',
+    map_class  => 'SL::DB::EmployeeProjectInvoices',
+  },
+);
+
 __PACKAGE__->meta->initialize;
 
 sub has_right {
diff --git a/SL/DB/EmployeeProjectInvoices.pm b/SL/DB/EmployeeProjectInvoices.pm
new file mode 100644
index 000000000..e45196a86
--- /dev/null
+++ b/SL/DB/EmployeeProjectInvoices.pm
@@ -0,0 +1,10 @@
+package SL::DB::EmployeeProjectInvoices;
+
+use strict;
+
+use SL::DB::MetaSetup::EmployeeProjectInvoices;
+use SL::DB::Manager::EmployeeProjectInvoices;
+
+__PACKAGE__->meta->initialize;
+
+1;
diff --git a/SL/DB/Helper/ALL.pm b/SL/DB/Helper/ALL.pm
index 0e6ed0739..5698036e8 100644
--- a/SL/DB/Helper/ALL.pm
+++ b/SL/DB/Helper/ALL.pm
@@ -52,6 +52,7 @@ use SL::DB::DunningConfig;
 use SL::DB::EmailJournal;
 use SL::DB::EmailJournalAttachment;
 use SL::DB::Employee;
+use SL::DB::EmployeeProjectInvoices;
 use SL::DB::Exchangerate;
 use SL::DB::File;
 use SL::DB::Finanzamt;
diff --git a/SL/DB/Helper/Mappings.pm b/SL/DB/Helper/Mappings.pm
index 6290bd3f4..12e56d060 100644
--- a/SL/DB/Helper/Mappings.pm
+++ b/SL/DB/Helper/Mappings.pm
@@ -136,6 +136,7 @@ my %kivitendo_package_names = (
   email_journal                  => 'EmailJournal',
   email_journal_attachments      => 'EmailJournalAttachment',
   employee                       => 'employee',
+  employee_project_invoices      => 'EmployeeProjectInvoices',
   exchangerate                   => 'exchangerate',
   files                          => 'file',
   finanzamt                      => 'finanzamt',
diff --git a/SL/DB/Manager/EmployeeProjectInvoices.pm b/SL/DB/Manager/EmployeeProjectInvoices.pm
new file mode 100644
index 000000000..a176ef4de
--- /dev/null
+++ b/SL/DB/Manager/EmployeeProjectInvoices.pm
@@ -0,0 +1,11 @@
+package SL::DB::Manager::EmployeeProjectInvoices;
+
+use strict;
+
+use parent qw(SL::DB::Helper::Manager);
+
+sub object_class { 'SL::DB::EmployeeProjectInvoices' }
+
+__PACKAGE__->make_manager_methods;
+
+1;
diff --git a/SL/DB/MetaSetup/EmployeeProjectInvoices.pm b/SL/DB/MetaSetup/EmployeeProjectInvoices.pm
new file mode 100644
index 000000000..c3ef6dee0
--- /dev/null
+++ b/SL/DB/MetaSetup/EmployeeProjectInvoices.pm
@@ -0,0 +1,31 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::EmployeeProjectInvoices;
+
+use strict;
+
+use parent qw(SL::DB::Object);
+
+__PACKAGE__->meta->table('employee_project_invoices');
+
+__PACKAGE__->meta->columns(
+  employee_id => { type => 'integer', not_null => 1 },
+  project_id  => { type => 'integer', not_null => 1 },
+);
+
+__PACKAGE__->meta->primary_key_columns([ 'employee_id', 'project_id' ]);
+
+__PACKAGE__->meta->foreign_keys(
+  employee => {
+    class       => 'SL::DB::Employee',
+    key_columns => { employee_id => 'id' },
+  },
+
+  project => {
+    class       => 'SL::DB::Project',
+    key_columns => { project_id => 'id' },
+  },
+);
+
+1;
+;
diff --git a/SL/DB/Project.pm b/SL/DB/Project.pm
index df5fe09ec..54975350c 100644
--- a/SL/DB/Project.pm
+++ b/SL/DB/Project.pm
@@ -12,6 +12,13 @@ use SL::DB::Helper::CustomVariables(
   cvars_alias => 1,
 );
 
+__PACKAGE__->meta->add_relationship(
+  employee_invoice_permissions  => {
+    type       => 'many to many',
+    map_class  => 'SL::DB::EmployeeProjectInvoices',
+  },
+);
+
 __PACKAGE__->meta->initialize;
 
 sub validate {
@@ -84,6 +91,23 @@ sub full_description {
   return $description;
 }
 
+sub may_employee_view_project_invoices {
+  my ($self, $employee) = @_;
+
+  return undef if !$self->id;
+
+  my $employee_id = ref($employee) ? $employee->id : $employee * 1;
+  my $query       = <<EOSQL;
+    SELECT project_id
+    FROM employee_project_invoices
+    WHERE (employee_id = ?)
+      AND (project_id  = ?)
+    LIMIT 1
+EOSQL
+
+  return !!$self->db->dbh->selectrow_arrayref($query, undef, $employee_id, $self->id)->[0];
+}
+
 1;
 
 __END__
diff --git a/bin/mozilla/ar.pl b/bin/mozilla/ar.pl
index 3706598fb..a3a8a630f 100644
--- a/bin/mozilla/ar.pl
+++ b/bin/mozilla/ar.pl
@@ -89,6 +89,20 @@ use strict;
 # $locale->text('Nov')
 # $locale->text('Dec')
 
+sub _may_view_or_edit_this_invoice {
+  return 1 if  $::auth->assert('ar_transactions', 1); # may edit all invoices
+  return 0 if !$::form->{id};                         # creating new invoices isn't allowed without invoice_edit
+  return 0 if !$::form->{globalproject_id};           # existing records without a project ID are not allowed
+  return SL::DB::Project->new(id => $::form->{globalproject_id})->load->may_employee_view_project_invoices(SL::DB::Manager::Employee->current);
+}
+
+sub _assert_access {
+  my $cache = $::request->cache('ar.pl::_assert_access');
+
+  $cache->{_may_view_or_edit_this_invoice} = _may_view_or_edit_this_invoice()                              if !exists $cache->{_may_view_or_edit_this_invoice};
+  $::form->show_generic_error($::locale->text("You do not have the permissions to access this function.")) if !       $cache->{_may_view_or_edit_this_invoice};
+}
+
 sub load_record_template {
   $::auth->assert('ar_transactions');
 
@@ -249,7 +263,9 @@ sub add {
 sub edit {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('ar_transactions');
+  # Delay access check to after the invoice's been loaded in
+  # "create_links" so that project-specific invoice rights can be
+  # evaluated.
 
   my $form     = $main::form;
 
@@ -268,7 +284,7 @@ sub edit {
 sub display_form {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('ar_transactions');
+  _assert_access();
 
   my $form     = $main::form;
 
@@ -287,7 +303,8 @@ sub _retrieve_invoice_object {
 sub create_links {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('ar_transactions');
+  # Delay access check to after the invoice's been loaded so that
+  # project-specific invoice rights can be evaluated.
 
   my %params   = @_;
   my $form     = $main::form;
@@ -296,6 +313,8 @@ sub create_links {
   $form->create_links("AR", \%myconfig, "customer");
   $form->{invoice_obj} = _retrieve_invoice_object();
 
+  _assert_access();
+
   my %saved;
   if (!$params{dont_save}) {
     %saved = map { ($_ => $form->{$_}) } qw(direct_debit id taxincluded);
@@ -329,7 +348,7 @@ sub create_links {
 sub form_header {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('ar_transactions');
+  _assert_access();
 
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
@@ -528,7 +547,7 @@ sub form_header {
 sub form_footer {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('ar_transactions');
+  _assert_access();
 
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
@@ -913,8 +932,6 @@ sub setup_ar_transactions_action_bar {
 sub search {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('invoice_edit');
-
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
   my $locale   = $main::locale;
@@ -969,8 +986,6 @@ sub create_subtotal_row {
 sub ar_transactions {
   $main::lxdebug->enter_sub();
 
-  $main::auth->assert('invoice_edit');
-
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
   my $locale   = $main::locale;
@@ -1254,6 +1269,7 @@ sub setup_ar_form_header_action_bar {
 
   my $is_storno               = IS->is_storno(\%::myconfig, $::form, 'ar', $::form->{id});
   my $has_storno              = IS->has_storno(\%::myconfig, $::form, 'ar');
+  my $may_edit_create         = $::auth->assert('ar_transactions', 1);
 
   for my $bar ($::request->layout->get('actionbar')) {
     $bar->add(
@@ -1262,6 +1278,7 @@ sub setup_ar_form_header_action_bar {
         submit    => [ '#form', { action => "update" } ],
         id        => 'update_button',
         checks    => [ 'kivi.validate_form' ],
+        disabled  => !$may_edit_create ? t8('You must not change this AR transaction.') : undef,
         accesskey => 'enter',
       ],
 
@@ -1270,7 +1287,8 @@ sub setup_ar_form_header_action_bar {
           t8('Post'),
           submit   => [ '#form', { action => "post" } ],
           checks   => [ 'kivi.validate_form', 'kivi.AR.check_fields_before_posting' ],
-          disabled => $is_closed                                  ? t8('The billing period has already been locked.')
+          disabled => !$may_edit_create                           ? t8('You must not change this AR transaction.')
+                    : $is_closed                                  ? t8('The billing period has already been locked.')
                     : $is_storno                                  ? t8('A canceled invoice cannot be posted.')
                     : ($::form->{id} && $change_never)            ? t8('Changing invoices has been disabled in the configuration.')
                     : ($::form->{id} && $change_on_same_day_only) ? t8('Invoices can only be changed on the day they are posted.')
@@ -1279,12 +1297,16 @@ sub setup_ar_form_header_action_bar {
         action => [
           t8('Post Payment'),
           submit   => [ '#form', { action => "post_payment" } ],
-          disabled => !$::form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this AR transaction.')
+                    : !$::form->{id}    ? t8('This invoice has not been posted yet.')
+                    :                     undef,
         ],
         action => [ t8('Mark as paid'),
           submit   => [ '#form', { action => "mark_as_paid" } ],
           confirm  => t8('This will remove the invoice from showing as unpaid even if the unpaid amount does not match the amount. Proceed?'),
-          disabled => !$::form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this AR transaction.')
+                    : !$::form->{id}    ? t8('This invoice has not been posted yet.')
+                    :                     undef,
           only_if  => $::instance_conf->get_is_show_mark_as_paid,
         ],
       ], # end of combobox "Post"
@@ -1294,16 +1316,18 @@ sub setup_ar_form_header_action_bar {
           submit   => [ '#form', { action => "storno" } ],
           checks   => [ 'kivi.validate_form', 'kivi.AR.check_fields_before_posting' ],
           confirm  => t8('Do you really want to cancel this invoice?'),
-          disabled => !$::form->{id}         ? t8('This invoice has not been posted yet.')
-                      : $has_storno          ? t8('This invoice has been canceled already.')
-                      : $is_storno           ? t8('Reversal invoices cannot be canceled.')
-                      : $::form->{totalpaid} ? t8('Invoices with payments cannot be canceled.')
-                      :                        undef,
+          disabled => !$may_edit_create    ? t8('You must not change this AR transaction.')
+                    : !$::form->{id}       ? t8('This invoice has not been posted yet.')
+                    : $has_storno          ? t8('This invoice has been canceled already.')
+                    : $is_storno           ? t8('Reversal invoices cannot be canceled.')
+                    : $::form->{totalpaid} ? t8('Invoices with payments cannot be canceled.')
+                    :                        undef,
         ],
         action => [ t8('Delete'),
           submit   => [ '#form', { action => "delete" } ],
           confirm  => t8('Do you really want to delete this object?'),
-          disabled => !$::form->{id}           ? t8('This invoice has not been posted yet.')
+          disabled => !$may_edit_create        ? t8('You must not change this AR transaction.')
+                    : !$::form->{id}           ? t8('This invoice has not been posted yet.')
                     : $change_never            ? t8('Changing invoices has been disabled in the configuration.')
                     : $change_on_same_day_only ? t8('Invoices can only be changed on the day they are posted.')
                     : $is_closed               ? t8('The billing period has already been locked.')
@@ -1319,7 +1343,9 @@ sub setup_ar_form_header_action_bar {
           t8('Use As New'),
           submit   => [ '#form', { action => "use_as_new" } ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$::form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this AR transaction.')
+                    : !$::form->{id} ? t8('This invoice has not been posted yet.')
+                    :                  undef,
         ],
       ], # end of combobox "Workflow"
 
@@ -1337,14 +1363,16 @@ sub setup_ar_form_header_action_bar {
         ],
         action => [
           t8('Record templates'),
-          call => [ 'kivi.RecordTemplate.popup', 'ar_transaction' ],
+          call     => [ 'kivi.RecordTemplate.popup', 'ar_transaction' ],
+          disabled => !$may_edit_create ? t8('You must not change this AR transaction.') : undef,
         ],
         action => [
           t8('Drafts'),
           call     => [ 'kivi.Draft.popup', 'ar', 'invoice', $::form->{draft_id}, $::form->{draft_description} ],
-          disabled => $::form->{id} ? t8('This invoice has already been posted.')
-                    : $is_closed    ? t8('The billing period has already been locked.')
-                    :                 undef,
+          disabled => !$may_edit_create ? t8('You must not change this AR transaction.')
+                    : $::form->{id}     ? t8('This invoice has already been posted.')
+                    : $is_closed        ? t8('The billing period has already been locked.')
+                    :                     undef,
         ],
       ], # end of combobox "more"
     );
diff --git a/bin/mozilla/is.pl b/bin/mozilla/is.pl
index a9e2963de..abbcef771 100644
--- a/bin/mozilla/is.pl
+++ b/bin/mozilla/is.pl
@@ -58,6 +58,20 @@ use strict;
 
 # end of main
 
+sub _may_view_or_edit_this_invoice {
+  return 1 if  $::auth->assert('invoice_edit', 1); # may edit all invoices
+  return 0 if !$::form->{id};                      # creating new invoices isn't allowed without invoice_edit
+  return 0 if !$::form->{globalproject_id};        # existing records without a project ID are not allowed
+  return SL::DB::Project->new(id => $::form->{globalproject_id})->load->may_employee_view_project_invoices(SL::DB::Manager::Employee->current);
+}
+
+sub _assert_access {
+  my $cache = $::request->cache('is.pl::_assert_access');
+
+  $cache->{_may_view_or_edit_this_invoice} = _may_view_or_edit_this_invoice()                              if !exists $cache->{_may_view_or_edit_this_invoice};
+  $::form->show_generic_error($::locale->text("You do not have the permissions to access this function.")) if !       $cache->{_may_view_or_edit_this_invoice};
+}
+
 sub add {
   $main::lxdebug->enter_sub();
 
@@ -92,11 +106,13 @@ sub add {
 sub edit {
   $main::lxdebug->enter_sub();
 
+  # Delay access check to after the invoice's been loaded in
+  # "invoice_links" so that project-specific invoice rights can be
+  # evaluated.
+
   my $form     = $main::form;
   my $locale   = $main::locale;
 
-  $main::auth->assert('invoice_edit');
-
   $form->{show_details}                = $::myconfig{show_form_details};
   $form->{taxincluded_changed_by_user} = 1;
 
@@ -134,16 +150,19 @@ sub edit {
 sub invoice_links {
   $main::lxdebug->enter_sub();
 
+  # Delay access check to after the invoice's been loaded so that
+  # project-specific invoice rights can be evaluated.
+
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
 
-  $main::auth->assert('invoice_edit');
-
   $form->{vc} = 'customer';
 
   # create links
   $form->create_links("AR", \%myconfig, "customer");
 
+  _assert_access();
+
   my $editing = $form->{id};
 
   $form->backup_vars(qw(payment_id language_id taxzone_id salesman_id
@@ -206,11 +225,11 @@ sub invoice_links {
 sub prepare_invoice {
   $main::lxdebug->enter_sub();
 
+  _assert_access();
+
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
 
-  $main::auth->assert('invoice_edit');
-
   if ($form->{type} eq "credit_note") {
     $form->{type}     = "credit_note";
     $form->{formname} = "credit_note";
@@ -258,13 +277,16 @@ sub setup_is_action_bar {
   my $change_on_same_day_only = $::instance_conf->get_is_changeable == 2 && ($form->current_date(\%::myconfig) ne $form->{gldate});
   my $payments_balanced       = ($::form->{oldtotalpaid} == 0);
   my $has_storno              = ($::form->{storno} && !$::form->{storno_id});
+  my $may_edit_create         = $::auth->assert('invoice_edit', 1);
 
   for my $bar ($::request->layout->get('actionbar')) {
     $bar->add(
       action => [
         t8('Update'),
         submit    => [ '#form', { action => "update" } ],
-        disabled  => $form->{locked} ? t8('The billing period has already been locked.') : undef,
+        disabled  => !$may_edit_create ? t8('You must not change this invoice.')
+                   : $form->{locked}   ? t8('The billing period has already been locked.')
+                   :                     undef,
         id        => 'update_button',
         checks    => [ 'kivi.validate_form' ],
         accesskey => 'enter',
@@ -275,7 +297,8 @@ sub setup_is_action_bar {
           t8('Post'),
           submit   => [ '#form', { action => "post" } ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => $form->{locked}                           ? t8('The billing period has already been locked.')
+          disabled => !$may_edit_create                         ? t8('You must not change this invoice.')
+                    : $form->{locked}                           ? t8('The billing period has already been locked.')
                     : $form->{storno}                           ? t8('A canceled invoice cannot be posted.')
                     : ($form->{id} && $change_never)            ? t8('Changing invoices has been disabled in the configuration.')
                     : ($form->{id} && $change_on_same_day_only) ? t8('Invoices can only be changed on the day they are posted.')
@@ -285,12 +308,16 @@ sub setup_is_action_bar {
           t8('Post Payment'),
           submit   => [ '#form', { action => "post_payment" } ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this invoice.')
+                    : !$form->{id}      ? t8('This invoice has not been posted yet.')
+                    :                     undef,
         ],
         action => [ t8('Mark as paid'),
           submit   => [ '#form', { action => "mark_as_paid" } ],
           confirm  => t8('This will remove the invoice from showing as unpaid even if the unpaid amount does not match the amount. Proceed?'),
-          disabled => !$form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this invoice.')
+                    : !$form->{id}      ? t8('This invoice has not been posted yet.')
+                    :                     undef,
           only_if  => $::instance_conf->get_is_show_mark_as_paid,
         ],
       ], # end of combobox "Post"
@@ -300,7 +327,8 @@ sub setup_is_action_bar {
           submit   => [ '#form', { action => "storno" } ],
           confirm  => t8('Do you really want to cancel this invoice?'),
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id}        ? t8('This invoice has not been posted yet.')
+          disabled => !$may_edit_create   ? t8('You must not change this invoice.')
+                    : !$form->{id}        ? t8('This invoice has not been posted yet.')
                     : !$payments_balanced ? t8('Cancelling is disallowed. Either undo or balance the current payments until the open amount matches the invoice amount')
                     : undef,
         ],
@@ -308,7 +336,8 @@ sub setup_is_action_bar {
           submit   => [ '#form', { action => "delete" } ],
           confirm  => t8('Do you really want to delete this object?'),
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id}             ? t8('This invoice has not been posted yet.')
+          disabled => !$may_edit_create        ? t8('You must not change this invoice.')
+                    : !$form->{id}             ? t8('This invoice has not been posted yet.')
                     : $form->{locked}          ? t8('The billing period has already been locked.')
                     : $change_never            ? t8('Changing invoices has been disabled in the configuration.')
                     : $change_on_same_day_only ? t8('Invoices can only be changed on the day they are posted.')
@@ -325,13 +354,16 @@ sub setup_is_action_bar {
           t8('Use As New'),
           submit   => [ '#form', { action => "use_as_new" } ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not change this invoice.')
+                    : !$form->{id}      ? t8('This invoice has not been posted yet.')
+                    :                     undef,
         ],
         action => [
           t8('Credit Note'),
           submit   => [ '#form', { action => "credit_note" } ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => $form->{type} eq "credit_note" ? t8('Credit notes cannot be converted into other credit notes.')
+          disabled => !$may_edit_create              ? t8('You must not change this invoice.')
+                    : $form->{type} eq "credit_note" ? t8('Credit notes cannot be converted into other credit notes.')
                     : !$form->{id}                   ? t8('This invoice has not been posted yet.')
                     :                                  undef,
         ],
@@ -349,17 +381,23 @@ sub setup_is_action_bar {
           ($form->{id} ? t8('Print') : t8('Preview')),
           call     => [ 'kivi.SalesPurchase.show_print_dialog', $form->{id} ? 'print' : 'preview' ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id} && $form->{locked} ? t8('The billing period has already been locked.') : undef,
+          disabled => !$may_edit_create               ? t8('You must not print this invoice.')
+                    : !$form->{id} && $form->{locked} ? t8('The billing period has already been locked.')
+                    :                                   undef,
         ],
         action => [ t8('Print and Post'),
           call     => [ 'kivi.SalesPurchase.show_print_dialog', $form->{id} ? 'print' : 'print_and_post' ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => $form->{id} ? t8('This invoice has already been posted.') : undef,,
+          disabled => !$may_edit_create ? t8('You must not print this invoice.')
+                    : $form->{id}       ? t8('This invoice has already been posted.')
+                    :                     undef,,
         ],
         action => [ t8('E Mail'),
           call     => [ 'kivi.SalesPurchase.show_email_dialog' ],
           checks   => [ 'kivi.validate_form' ],
-          disabled => !$form->{id} ? t8('This invoice has not been posted yet.') : undef,
+          disabled => !$may_edit_create ? t8('You must not print this invoice.')
+                    : !$form->{id}      ? t8('This invoice has not been posted yet.')
+                    :                     undef,
         ],
       ], # end of combobox "Export"
 
@@ -378,9 +416,10 @@ sub setup_is_action_bar {
         action => [
           t8('Drafts'),
           call     => [ 'kivi.Draft.popup', 'is', 'invoice', $form->{draft_id}, $form->{draft_description} ],
-          disabled => $form->{id}     ? t8('This invoice has already been posted.')
-                    : $form->{locked} ? t8('The billing period has already been locked.')
-                    :                   undef,
+          disabled => !$may_edit_create ? t8('You must not change this invoice.')
+                    :  $form->{id}      ? t8('This invoice has already been posted.')
+                    : $form->{locked}   ? t8('The billing period has already been locked.')
+                    :                     undef,
         ],
       ], # end of combobox "more"
     );
@@ -391,13 +430,13 @@ sub setup_is_action_bar {
 sub form_header {
   $main::lxdebug->enter_sub();
 
+  _assert_access();
+
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
   my $locale   = $main::locale;
   my $cgi      = $::request->{cgi};
 
-  $main::auth->assert('invoice_edit');
-
   my %TMPL_VAR = ();
   my @custom_hiddens;
 
@@ -526,12 +565,12 @@ sub _sort_payments {
 sub form_footer {
   $main::lxdebug->enter_sub();
 
+  _assert_access();
+
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
   my $locale   = $main::locale;
 
-  $main::auth->assert('invoice_edit');
-
   $form->{invtotal}    = $form->{invsubtotal};
 
   # note rows
@@ -658,11 +697,11 @@ sub show_draft {
 sub update {
   $main::lxdebug->enter_sub();
 
+  _assert_access();
+
   my $form     = $main::form;
   my %myconfig = %main::myconfig;
 
-  $main::auth->assert('invoice_edit');
-
   my ($recursive_call) = @_;
 
   $form->{print_and_post} = 0         if $form->{second_run};
@@ -1180,7 +1219,7 @@ sub credit_note {
 sub display_form {
   $::lxdebug->enter_sub;
 
-  $::auth->assert('invoice_edit');
+  _assert_access();
 
   relink_accounts();
 
diff --git a/locale/de/all b/locale/de/all
index ece3072e6..175a80548 100755
--- a/locale/de/all
+++ b/locale/de/all
@@ -250,6 +250,7 @@ $self->{texts} = {
   'All as list'                 => 'Alle als Liste',
   'All changes in that file have been reverted.' => 'Alle Änderungen in dieser Datei wurden rückgängig gemacht.',
   'All clients'                 => 'Alle Mandanten',
+  'All employees'               => 'Alle Angestellten',
   'All general ledger entries'  => 'Alle Hauptbucheinträge',
   'All groups'                  => 'Alle Gruppen',
   'All modules'                 => 'Alle Module',
@@ -1233,6 +1234,7 @@ $self->{texts} = {
   'Employee (database ID)'      => 'Bearbeiter (Datenbank-ID)',
   'Employee from the original invoice' => 'Mitarbeiter der Ursprungs-Rechnung',
   'Employees'                   => 'Benutzer',
+  'Employees with read access to the project\'s invoices' => 'Angestellte mit Leserechten auf die Projektrechnungen',
   'Empty selection for warehouse will not be added, even if the old bin is still visible (use back and forth to edit again).' => 'Leere Lager-Auswahl wird ignoriert, selbst wenn noch ein Lagerplatz ausgewählt ist. Alle Daten können durch zurück und vorwärts korrigiert werden.',
   'Empty transaction!'          => 'Buchung ist leer!',
   'Enabled Quick Searched'      => 'Aktivierte Schnellsuchen',
@@ -2267,6 +2269,7 @@ $self->{texts} = {
   'Periodic inventory'          => 'Aufwandsmethode',
   'Periodic invoices active'    => 'Wiederkehrende Rechnungen aktiv',
   'Periodic invoices inactive'  => 'Wiederkehrende Rechnungen inaktiv',
+  'Permissions for invoices'    => 'Ansehrechte für Rechnungen',
   'Perpetual inventory'         => 'Bestandsmethode',
   'Personal settings'           => 'Persönliche Einstellungen',
   'Phone'                       => 'Telefon',
@@ -2451,6 +2454,7 @@ $self->{texts} = {
   'Project type'                => 'Projekttyp',
   'Project types'               => 'Projekttypen',
   'Projects'                    => 'Projekte',
+  'Projects: edit the list of employees allowed to view invoices' => 'Projekte: Liste der Angestellten bearbeiten, die Projektrechnungen ansehen dürfen',
   'Projecttransactions'         => 'Projektbuchungen',
   'Proposal'                    => 'Vorschlag',
   'Proposals'                   => 'Vorschläge',
@@ -3922,6 +3926,9 @@ $self->{texts} = {
   'You have to specify an execution date for each antry.' => 'Sie müssen für jeden zu buchenden Eintrag ein Ausführungsdatum angeben.',
   'You must chose a user.'      => 'Sie müssen einen Benutzer auswählen.',
   'You must enter a name for your new print templates.' => 'Sie müssen einen Namen für die neuen Druckvorlagen angeben.',
+  'You must not change this AR transaction.' => 'Sie dürfen diese Debitorenbuchung nicht verändern.',
+  'You must not change this invoice.' => 'Sie dürfen diese Rechnung nicht verändern.',
+  'You must not print this invoice.' => 'Sie dürfen diese Rechnung nicht drucken.',
   'You must select existing print templates or create a new set.' => 'Sie müssen vorhandene Druckvorlagen auswählen oder einen neuen Satz anlegen.',
   'You should create a backup of the database before proceeding because the backup might not be reversible.' => 'Sie sollten eine Sicherungskopie der Datenbank erstellen, bevor Sie fortfahren, da die Aktualisierung unter Umständen nicht umkehrbar ist.',
   'You\'re not editing a file.' => 'Sie bearbeiten momentan keine Datei.',
diff --git a/menus/user/00-erp.yaml b/menus/user/00-erp.yaml
index d3c006cbb..91cbb2449 100644
--- a/menus/user/00-erp.yaml
+++ b/menus/user/00-erp.yaml
@@ -308,7 +308,6 @@
   name: Invoices, Credit Notes & AR Transactions
   icon: invoices_report
   order: 500
-  access: invoice_edit
   module: ar.pl
   params:
     action: search
diff --git a/sql/Pg-upgrade2-auth/rights_for_viewing_project_specific_invoices.sql b/sql/Pg-upgrade2-auth/rights_for_viewing_project_specific_invoices.sql
new file mode 100644
index 000000000..c08eab1ae
--- /dev/null
+++ b/sql/Pg-upgrade2-auth/rights_for_viewing_project_specific_invoices.sql
@@ -0,0 +1,18 @@
+-- @tag: rights_for_viewing_project_specific_invoices
+-- @description: Rechte zum Anzeigen von Rechnungen, die zu Projekten gehören
+-- @depends: release_3_5_3
+-- @locales: Projects: edit the list of employees allowed to view invoices
+INSERT INTO auth.master_rights (position, name, description, category)
+VALUES (
+  (SELECT position + 2
+   FROM auth.master_rights
+   WHERE name = 'project_edit'),
+  'project_edit_view_invoices_permission',
+  'Projects: edit the list of employees allowed to view invoices',
+  false
+);
+
+INSERT INTO auth.group_rights (group_id, "right", granted)
+SELECT id, 'project_edit_view_invoices_permission', true
+FROM auth.group
+WHERE name = 'Vollzugriff';
diff --git a/sql/Pg-upgrade2/add_emloyee_project_assignment_for_viewing_invoices.sql b/sql/Pg-upgrade2/add_emloyee_project_assignment_for_viewing_invoices.sql
new file mode 100644
index 000000000..2f2696855
--- /dev/null
+++ b/sql/Pg-upgrade2/add_emloyee_project_assignment_for_viewing_invoices.sql
@@ -0,0 +1,11 @@
+-- @tag: add_emloyee_project_assignment_for_viewing_invoices
+-- @description: Mitarbeiter*innen Projekten zuweisen können, damit diese Projektrechnungen anschauen dürfen
+-- @depends: release_3_5_3
+CREATE TABLE employee_project_invoices (
+  employee_id INTEGER NOT NULL,
+  project_id  INTEGER NOT NULL,
+
+  CONSTRAINT employee_project_invoices_pkey             PRIMARY KEY (employee_id, project_id),
+  CONSTRAINT employee_project_invoices_employee_id_fkey FOREIGN KEY (employee_id) REFERENCES employee (id) ON DELETE CASCADE,
+  CONSTRAINT employee_project_invoices_project_id_fkey  FOREIGN KEY (project_id)  REFERENCES project  (id) ON DELETE CASCADE
+);
diff --git a/templates/webpages/project/_invoice_permissions.html b/templates/webpages/project/_invoice_permissions.html
new file mode 100644
index 000000000..64197487c
--- /dev/null
+++ b/templates/webpages/project/_invoice_permissions.html
@@ -0,0 +1,4 @@
+[%- USE LxERP -%][%- USE L -%]<div class="clearfix">
+ [% L.select_tag("project.employee_invoice_permissions[]", SELF.employees, id="employee_invoice_permissions", title_key="safe_name", default=SELF.project.employee_invoice_permissions, default_value_key='id', multiple=1) %]
+ [% L.multiselect2side("employee_invoice_permissions", labelsx => LxERP.t8("All employees"), labeldx => LxERP.t8("Employees with read access to the project's invoices")) %]
+</div>
diff --git a/templates/webpages/project/form.html b/templates/webpages/project/form.html
index b8343fb39..67df852e4 100644
--- a/templates/webpages/project/form.html
+++ b/templates/webpages/project/form.html
@@ -17,6 +17,9 @@
     [%- IF CUSTOM_VARIABLES.size %]
     <li><a href="#custom_variables">[% 'Custom Variables' | $T8 %]</a></li>
     [%- END %]
+    [%- IF SELF.may_edit_invoice_permissions %]
+     <li><a href="#invoice_permissions">[% 'Permissions for invoices' | $T8 %]</a></li>
+    [%- END %]
     [%- IF SELF.project.id and AUTH.assert('record_links', 1) %]
     <li><a href="#linked_records">[% 'Linked Records' | $T8 %]</a></li>
     [%- END %]
@@ -32,6 +35,12 @@
    </div>
    [%- END %]
 
+   [%- IF SELF.may_edit_invoice_permissions %]
+    <div id="invoice_permissions">
+     [%- PROCESS 'project/_invoice_permissions.html' %]
+    </div>
+   [%- END %]
+
    [%- IF SELF.project.id and AUTH.assert('record_links', 1) %]
    <div id="linked_records">
    [%- PROCESS 'project/_linked_records.html' records=SELF.linked_records %]
