diff --git a/src/main/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactEntity.java index ac7779e7..4b8953f9 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactEntity.java @@ -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 diff --git a/src/main/resources/db/changelog/010-context.sql b/src/main/resources/db/changelog/010-context.sql index d2e8370d..cee7f778 100644 --- a/src/main/resources/db/changelog/010-context.sql +++ b/src/main/resources/db/changelog/010-context.sql @@ -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; $$; --// diff --git a/src/main/resources/db/changelog/050-rbac-base.sql b/src/main/resources/db/changelog/050-rbac-base.sql index daedc552..48dcdb4b 100644 --- a/src/main/resources/db/changelog/050-rbac-base.sql +++ b/src/main/resources/db/changelog/050-rbac-base.sql @@ -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 diff --git a/src/main/resources/db/changelog/057-rbac-role-builder.sql b/src/main/resources/db/changelog/057-rbac-role-builder.sql index 79db89f0..941985b7 100644 --- a/src/main/resources/db/changelog/057-rbac-role-builder.sql +++ b/src/main/resources/db/changelog/057-rbac-role-builder.sql @@ -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; $$; + --// -- ================================================================= diff --git a/src/main/resources/db/changelog/113-test-customer-rbac.sql b/src/main/resources/db/changelog/113-test-customer-rbac.sql index 3124883f..767506fc 100644 --- a/src/main/resources/db/changelog/113-test-customer-rbac.sql +++ b/src/main/resources/db/changelog/113-test-customer-rbac.sql @@ -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(); --// diff --git a/src/main/resources/db/changelog/200-hs-admin-contact.sql b/src/main/resources/db/changelog/200-hs-admin-contact.sql new file mode 100644 index 00000000..4e2c89bd --- /dev/null +++ b/src/main/resources/db/changelog/200-hs-admin-contact.sql @@ -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 +); +--// diff --git a/src/main/resources/db/changelog/203-hs-admin-contact-rbac.sql b/src/main/resources/db/changelog/203-hs-admin-contact-rbac.sql new file mode 100644 index 00000000..42bc3795 --- /dev/null +++ b/src/main/resources/db/changelog/203-hs-admin-contact-rbac.sql @@ -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(); +--// + diff --git a/src/main/resources/db/changelog/208-hs-admin-contact-test-data.sql b/src/main/resources/db/changelog/208-hs-admin-contact-test-data.sql new file mode 100644 index 00000000..5c890a4a --- /dev/null +++ b/src/main/resources/db/changelog/208-hs-admin-contact-test-data.sql @@ -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; +$$; +--// diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 160a2388..cbf1161b 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -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 + diff --git a/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactRepositoryIntegrationTest.java index 24656dec..6354d54f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/HsAdminContactRepositoryIntegrationTest.java @@ -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; - @Test - void test() throws UnsupportedEncodingException, NoSuchAlgorithmException { +@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 + 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 actualResult, final String... contactLabels) { + assertThat(actualResult) + .hasSize(contactLabels.length) + .extracting(HsAdminContactEntity::getLabel) + .containsExactlyInAnyOrder(contactLabels); + } + + void allTheseContactsAreReturned(final List actualResult, final String... contactLabels) { + assertThat(actualResult) + .extracting(HsAdminContactEntity::getLabel) + .contains(contactLabels); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/TestHsAdminContact.java b/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/TestHsAdminContact.java new file mode 100644 index 00000000..db77dcf1 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/admin/contact/TestHsAdminContact.java @@ -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(); + } +}