add hs_admin_contact table and repository with findContactByOptionalLabelLike+save

This commit is contained in:
Michael Hoennig 2022-09-06 19:43:15 +02:00
parent da793ee546
commit 2afdb3c3d7
11 changed files with 567 additions and 10 deletions

View File

@ -1,15 +1,15 @@
package net.hostsharing.hsadminng.hs.admin.contact;
import com.vladmihalcea.hibernate.type.array.ListArrayType;
import lombok.*;
import org.hibernate.annotations.TypeDef;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "hs_admin_contact_rv")
@Getter
@Setter
@Builder

View File

@ -148,7 +148,7 @@ begin
return string_to_array(currentSubject, ';');
end; $$;
create or replace function pureIdentifier(rawIdentifier varchar)
create or replace function cleanIdentifier(rawIdentifier varchar)
returns varchar
returns null on null input
language plpgsql as $$
@ -156,6 +156,17 @@ declare
cleanIdentifier varchar;
begin
cleanIdentifier := regexp_replace(rawIdentifier, '[^A-Za-z0-9\-._]+', '', 'g');
return cleanIdentifier;
end; $$;
create or replace function pureIdentifier(rawIdentifier varchar)
returns varchar
returns null on null input
language plpgsql as $$
declare
cleanIdentifier varchar;
begin
cleanIdentifier := cleanIdentifier(rawIdentifier);
if cleanIdentifier != rawIdentifier then
raise exception 'identifier "%" contains invalid characters, maybe use "%"', rawIdentifier, cleanIdentifier;
end if;
@ -211,11 +222,19 @@ declare
assumedRoles varchar(63)[];
begin
assumedRoles := assumedRoles();
if array_length(assumedRoles(), 1) > 0 then
if array_length(assumedRoles, 1) > 0 then
return assumedRoles();
else
return array [currentUser()]::varchar(63)[];
end if;
end; $$;
create or replace function hasAssumedRole()
returns boolean
stable leakproof
language plpgsql as $$
begin
return array_length(assumedRoles(), 1) > 0;
end; $$;
--//

View File

@ -280,6 +280,7 @@ create domain RbacOp as varchar(67)
or VALUE = 'view'
or VALUE = 'assume'
or VALUE ~ '^add-[a-z]+$'
or VALUE ~ '^set-[a-z]+$'
);
create table RbacPermission

View File

@ -114,6 +114,14 @@ begin
return beingItselfA(getRoleId(roleDescriptor, 'fail'));
end; $$;
create or replace function withoutSubRoles()
returns RbacSubRoles
language plpgsql
strict as $$
begin
return row (array []::uuid[]);
end; $$;
--//
-- =================================================================

View File

@ -234,7 +234,7 @@ create trigger test_customer_insert_trigger
before insert
on test_customer
for each row
when ( currentUser() <> 'alex@hostsharing.net' or not hasGlobalPermission('add-customer') )
when ( not hasGlobalPermission('add-customer') )
execute procedure addTestCustomerNotAllowedForCurrentSubjects();
--//

View File

@ -0,0 +1,15 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-admin-contact-MAIN-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create table if not exists hs_admin_contact
(
uuid uuid unique references RbacObject (uuid),
label varchar(96) not null,
postalAddress text,
emailAddresses text, -- TODO: change to json
phoneNumbers text -- TODO: change to json
);
--//

View File

@ -0,0 +1,295 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-admin-contact-rbac-CREATE-OBJECT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates the related RbacObject through a BEFORE INSERT TRIGGER.
*/
drop trigger if exists createRbacObjectForCustomer_Trigger on hs_admin_contact;
create trigger createRbacObjectForCustomer_Trigger
before insert
on hs_admin_contact
for each row
execute procedure createRbacObject();
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create or replace function hsAdminContactOwner(contact hs_admin_contact)
returns RbacRoleDescriptor
language plpgsql
strict as $$
begin
return roleDescriptor('hs_admin_contact', contact.uuid, 'owner');
end; $$;
create or replace function hsAdminContactOwner(contact hs_admin_contact)
returns RbacRoleDescriptor
language plpgsql
strict as $$
begin
return roleDescriptor('hs_admin_contact', contact.uuid, 'admin');
end; $$;
create or replace function hsAdminContactTenant(contact hs_admin_contact)
returns RbacRoleDescriptor
language plpgsql
strict as $$
begin
return roleDescriptor('hs_admin_contact', contact.uuid, 'tenant');
end; $$;
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-ROLES-CREATION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates the roles and their assignments for a new contact for the AFTER INSERT TRIGGER.
*/
create or replace function createRbacRolesForHsAdminContact()
returns trigger
language plpgsql
strict as $$
declare
contOwnerRole uuid;
begin
if TG_OP <> 'INSERT' then
raise exception 'invalid usage of TRIGGER AFTER INSERT';
end if;
-- the owner role with full access for the creator assigned to the contact's email addr
perform createRbacUser(NEW.emailaddresses);
contOwnerRole = createRole(
hsAdminContactOwner(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']),
beneathRole(globalAdmin()),
withoutSubRoles(),
withUsers(array[currentUser(), NEW.emailaddresses]), -- TODO: multiple
grantedByRole(globalAdmin())
);
-- the tenant role for those related users who can view the data
perform createRole(
hsAdminContactTenant(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']),
beneathRole(contOwnerRole)
);
return NEW;
end; $$;
/*
An AFTER INSERT TRIGGER which creates the role structure for a new customer.
*/
drop trigger if exists createRbacRolesForHsAdminContact_Trigger on hs_admin_contact;
create trigger createRbacRolesForHsAdminContact_Trigger
after insert
on hs_admin_contact
for each row
execute procedure createRbacRolesForHsAdminContact();
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-ROLES-REMOVAL:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Deletes the roles and their assignments of a deleted contact for the BEFORE DELETE TRIGGER.
*/
create or replace function deleteRbacRulesForHsAdminContact()
returns trigger
language plpgsql
strict as $$
begin
if TG_OP = 'DELETE' then
call deleteRole(findRoleId(hsAdminContactOwner(OLD)));
call deleteRole(findRoleId(hsAdminContactTenant(OLD)));
else
raise exception 'invalid usage of TRIGGER BEFORE DELETE';
end if;
end; $$;
/*
An BEFORE DELETE TRIGGER which deletes the role structure of a contact.
*/
drop trigger if exists deleteRbacRulesForHsAdminContact_Trigger on hs_admin_contact;
create trigger deleteRbacRulesForTestContact_Trigger
before delete
on hs_admin_contact
for each row
execute procedure deleteRbacRulesForHsAdminContact();
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-IDENTITY-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a view to the contact main table which maps the identifying name
(in this case, the prefix) to the objectUuid.
*/
drop view if exists hs_admin_contact_iv;
create or replace view hs_admin_contact_iv as
select target.uuid, cleanIdentifier(target.label) as idName
from hs_admin_contact as target;
-- TODO: Is it ok that everybody has access to this information?
grant all privileges on hs_admin_contact_iv to restricted;
/*
Returns the objectUuid for a given identifying name (in this case the prefix).
*/
create or replace function hs_admin_contactUuidByIdName(idName varchar)
returns uuid
language sql
strict as $$
select uuid from hs_admin_contact_iv iv where iv.idName = hs_admin_contactUuidByIdName.idName;
$$;
/*
Returns the identifying name for a given objectUuid (in this case the label).
*/
create or replace function hs_admin_contactIdNameByUuid(uuid uuid)
returns varchar
language sql
strict as $$
select idName from hs_admin_contact_iv iv where iv.uuid = hs_admin_contactIdNameByUuid.uuid;
$$;
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a view to the contact main table with row-level limitation
based on the 'view' permission of the current user or assumed roles.
*/
set session session authorization default;
drop view if exists hs_admin_contact_rv;
create or replace view hs_admin_contact_rv as
select target.*
from hs_admin_contact as target
where target.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('view', 'hs_admin_contact', currentSubjectsUuids()));
grant all privileges on hs_admin_contact_rv to restricted;
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-INSTEAD-OF-INSERT-TRIGGER:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/**
Instead of insert trigger function for hs_admin_contact_rv.
*/
create or replace function insertHsAdminContact()
returns trigger
language plpgsql as $$
declare
newUser hs_admin_contact;
begin
-- insert
-- into RbacObject as r (uuid, objecttable)
-- values( new.uuid, 'hs_admin_contact_rv');
insert
into hs_admin_contact
values (new.*)
returning * into newUser;
return newUser;
end;
$$;
/*
Creates an instead of insert trigger for the hs_admin_contact_rv view.
*/
create trigger insertHsAdminContact_Trigger
instead of insert
on hs_admin_contact_rv
for each row
execute function insertHsAdminContact();
--//
-- ============================================================================
--changeset hs-admin-contact-rbac-INSTEAD-OF-DELETE-TRIGGER:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/**
Instead of delete trigger function for hs_admin_contact_rv.
*/
create or replace function deleteHsAdminContact()
returns trigger
language plpgsql as $$
begin
if currentUserUuid() = old.uuid or hasGlobalRoleGranted(currentUserUuid()) then
delete from RbacUser where uuid = old.uuid;
return old;
end if;
-- TODO: check role permissions
raise exception '[403] User % not allowed to delete contact uuid %', currentUser(), old.uuid;
end; $$;
/*
Creates an instead of delete trigger for the RbacUser_rv view.
*/
create trigger deleteHsAdminContact_Trigger
instead of delete
on hs_admin_contact_rv
for each row
execute function deleteHsAdminContact();
--/
-- ============================================================================
--changeset hs-admin-contact-rbac-SET-CONTACT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a global permission for set-contact and assigns it to the hostsharing admins role.
*/
do language plpgsql $$
declare
addCustomerPermissions uuid[];
globalObjectUuid uuid;
globalAdminRoleUuid uuid ;
begin
call defineContext('granting global set-contact permission to global admin role', null, null, null);
globalAdminRoleUuid := findRoleId(globalAdmin());
globalObjectUuid := (select uuid from global);
addCustomerPermissions := createPermissions(globalObjectUuid, array ['set-contact']);
call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions);
end;
$$;
/**
Used by the trigger to prevent the add-customer to current user respectively assumed roles.
*/
create or replace function addHsAdminContactNotAllowedForCurrentSubjects()
returns trigger
language PLPGSQL
as $$
begin
raise exception '[403] set-contact not permitted for %',
array_to_string(currentSubjects(), ';', 'null');
end; $$;
/**
Checks if the user or assumed roles are allowed to create a new customer.
*/
create trigger hs_admin_contact_insert_trigger
before insert
on hs_admin_contact
for each row
-- TODO.spec: who is allowed to create new contacts
when ( not hasAssumedRole() )
execute procedure addHsAdminContactNotAllowedForCurrentSubjects();
--//

View File

@ -0,0 +1,65 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-admin-contact-TEST-DATA-GENERATOR:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a single contact test record.
*/
create or replace procedure createHsAdminContactTestData(contLabel varchar)
language plpgsql as $$
declare
currentTask varchar;
contRowId uuid;
contEmailAddr varchar;
begin
currentTask = 'creating RBAC test contact ' || contLabel;
call defineContext(currentTask, null, 'alex@hostsharing.net', 'global#global.admin');
execute format('set local hsadminng.currentTask to %L', currentTask);
-- contRowId = uuid_generate_v4();
contEmailAddr = 'customer-admin@' || cleanIdentifier(contLabel) || '.example.com';
raise notice 'creating test contact: %', contLabel;
insert
into hs_admin_contact (label, postaladdress, emailaddresses, phonenumbers)
values (contLabel, $addr$
Vorname Nachname
Straße Hnr
PLZ Stadt
$addr$, contEmailAddr, '+49 123 1234567');
end; $$;
--//
/*
Creates a range of test customers for mass data generation.
*/
create or replace procedure createTestCustomerTestData(
startCount integer, -- count of auto generated rows before the run
endCount integer -- count of auto generated rows after the run
)
language plpgsql as $$
begin
for t in startCount..endCount
loop
call createHsAdminContactTestData(intToVarChar(t, 4)|| ' ' || testCustomerReference(t));
commit;
end loop;
end; $$;
--//
-- ============================================================================
--changeset hs-admin-contact-TEST-DATA-GENERATION:1 context=dev,tc endDelimiter:--//
-- ----------------------------------------------------------------------------
do language plpgsql $$
begin
call createHsAdminContactTestData('first contact');
call createHsAdminContactTestData('second contact');
call createHsAdminContactTestData('third contact');
end;
$$;
--//

View File

@ -45,5 +45,12 @@ databaseChangeLog:
file: db/changelog/133-test-domain-rbac.sql
- include:
file: db/changelog/138-test-domain-test-data.sql
- include:
file: db/changelog/200-hs-admin-contact.sql
- include:
file: db/changelog/203-hs-admin-contact-rbac.sql
- include:
file: db/changelog/208-hs-admin-contact-test-data.sql

View File

@ -1,14 +1,144 @@
package net.hostsharing.hsadminng.hs.admin.contact;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.context.ContextBasedTest;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.test.annotation.DirtiesContext;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import javax.persistence.EntityManager;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
class HsAdminContactRepositoryIntegrationTest {
import static net.hostsharing.hsadminng.hs.admin.contact.TestHsAdminContact.hsAdminContact;
import static net.hostsharing.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ComponentScan(basePackageClasses = { Context.class, HsAdminContactRepository.class })
@DirtiesContext
class HsAdminContactRepositoryIntegrationTest extends ContextBasedTest {
@Autowired
HsAdminContactRepository contactRepo;
@Autowired
EntityManager em;
@MockBean
HttpServletRequest request;
@Nested
class CreateContact {
@Test
void test() throws UnsupportedEncodingException, NoSuchAlgorithmException {
public void globalAdmin_withoutAssumedRole_canCreateNewContact() {
// given
context("alex@hostsharing.net");
final var count = contactRepo.count();
// when
final var result = attempt(em, () -> contactRepo.save(
hsAdminContact("a new contact", "contact-admin@www.example.com")));
// then
assertThat(result.wasSuccessful()).isTrue();
assertThat(result.returnedValue()).isNotNull().extracting(HsAdminContactEntity::getUuid).isNotNull();
assertThatContactIsPersisted(result.returnedValue());
assertThat(contactRepo.count()).isEqualTo(count + 1);
}
@Test
public void arbitraryUser_canCreateNewContact() {
// given
context("pac-admin-xxx00@xxx.example.com");
final var count = contactRepo.count();
// when
final var result = attempt(em, () -> contactRepo.save(
hsAdminContact("another new contact", "another-new-contact@example.com")));
// then
assertThat(result.wasSuccessful()).isTrue();
assertThat(result.returnedValue()).isNotNull().extracting(HsAdminContactEntity::getUuid).isNotNull();
assertThatContactIsPersisted(result.returnedValue());
assertThat(contactRepo.count()).isEqualTo(count + 1);
}
private void assertThatContactIsPersisted(final HsAdminContactEntity saved) {
final var found = contactRepo.findByUuid(saved.getUuid());
assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved);
}
}
@Nested
class FindAllContacts {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllContacts() {
// given
context("alex@hostsharing.net");
// when
final var result = contactRepo.findContactByOptionalLabelLike(null);
// then
allTheseContactsAreReturned(result, "first contact", "second contact", "third contact");
}
@Test
public void arbitraryUser_canViewOnlyItsOwnContact() {
context("customer-admin@secondcontact.example.com");
final var result = contactRepo.findContactByOptionalLabelLike(null);
exactlyTheseContactsAreReturned(result, "second contact");
}
}
@Nested
class FindByPrefixLike {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllContacts() {
// given
context("alex@hostsharing.net", null);
// when
final var result = contactRepo.findContactByOptionalLabelLike("second");
// then
exactlyTheseContactsAreReturned(result, "second contact");
}
@Test
public void arbitraryUser_withoutAssumedRole_canViewOnlyItsOwnContact() {
// given:
context("customer-admin@secondcontact.example.com", null);
// when:
final var result = contactRepo.findContactByOptionalLabelLike("second contact");
// then:
exactlyTheseContactsAreReturned(result, "second contact");
}
}
void exactlyTheseContactsAreReturned(final List<HsAdminContactEntity> actualResult, final String... contactLabels) {
assertThat(actualResult)
.hasSize(contactLabels.length)
.extracting(HsAdminContactEntity::getLabel)
.containsExactlyInAnyOrder(contactLabels);
}
void allTheseContactsAreReturned(final List<HsAdminContactEntity> actualResult, final String... contactLabels) {
assertThat(actualResult)
.extracting(HsAdminContactEntity::getLabel)
.contains(contactLabels);
}
}

View File

@ -0,0 +1,17 @@
package net.hostsharing.hsadminng.hs.admin.contact;
import java.util.UUID;
public class TestHsAdminContact {
public static final HsAdminContactEntity someContact = hsAdminContact("some contact", "some-contact@example.com");
static public HsAdminContactEntity hsAdminContact(final String label, final String emailAddr) {
return HsAdminContactEntity.builder()
.uuid(UUID.randomUUID())
.label(label)
.postalAddress("address of " + label)
.emailAddresses(emailAddr)
.build();
}
}