diff --git a/doc/PR/2026-05-04-PR#221-make-projects-visible-to-debitors-despite-unassumed.md b/doc/PR/2026-05-04-PR#221-make-projects-visible-to-debitors-despite-unassumed.md new file mode 100644 index 00000000..920aaccd --- /dev/null +++ b/doc/PR/2026-05-04-PR#221-make-projects-visible-to-debitors-despite-unassumed.md @@ -0,0 +1,78 @@ +# PR#221: Make projects visible to debitors despite unassumed grant + +See also [Taiga#459: Project per Backend API nicht sichtbar](https://plan.hostsharing.net/project/admin-hsadmin/us/459) - just for internal tracking. + +## The Problem + +Even though, according to the database, there is at least one project for a given debitor, +it cannot be fetched via API: + +``` +curl --no-progress-meter -X 'GET' \ +'http://127.0.0.1:/api/hs/booking/projects?debitorUuid=' \ +-H "Authorization: Bearer $BEARER" \ +|jq|less +``` + +See that the response is an empty JSON array. + + +## The Cause + +To avoid that too many objects are visible at once, which might confuse the user and slows the ReBAC-system down, +the grant from a Debitor:AGENT to Project:OWNER is unassumed. +Unassumed means that the grant is only effective if the specific role (here Project:OWNER) is explicitly assumed. + +Unfortunately, here we have the problem that it's hard to assume the role of an object which cannot even be fetched, +as we need the UUID of the object to assume the role. + + +## The Solution + +The solution is to add a role Project:REFERRER, which is getting granted to the Debitor:AGENT and auto-assumed. +By this grant, the project becomes visible for debitors, not yet anything below the project. +Usually there are not that many projects, just on the next level there might be very many objects; +thus such a grant is not a problem. +Just for anything below, the owner role still needs to be assumed, which now is possible, +as the UUID of the debitor's project(s) is now known. + +This change is to be applied in the ReBAC-DSL of the HsBookingProjectRbacEntity: + +``` + // existing role + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("debitorRel", TENANT); + // with.permission(SELECT); moved to the REFERRER role + }) + // new role + .createSubRole(REFERRER, (with) -> { + // make the project visible for debitors, but for anything below, the owner role needs to be assumed + with.incomingSuperRole("debitorRel", AGENT); + with.permission(SELECT); + }) +``` + +Also have a look at the [updated diagram](../../src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md) + +The new test-cases: + +``` +@ValueSource(strings = { + "hs_office.relation#FirstGmbH-with-DEBITOR-FirstGmbH:ADMIN", // the debitor:ADMIN, failed before + "hs_booking.project#D-1000111-D-1000111defaultproject:OWNER", // the project:OWNER, worked before + "" // without any assumed-roles - failed before as well +}) +void debitorAdminUser_canGetRelatedBookingProjectEvenWithoutAssumingTheProjectRole(final String assumedRoles) +``` + +### **Attention:** This change does not directly apply to existing data. + +First, we needed to re-generate the PostgreSQL code by running +`net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRbacEntity.main`. +The generated PostgreSQL is part of the PR and applied directly to automated tests which use the Testcontainers PostgreSQL. + +But furthermore, we either need to-regenerate the roles+grants for all projects or, +as there is no production data for booking+hosting yet, simply re-generate the whole test-data. + +Now use the role-ID "hs_booking.project#:OWNER" in the "assumed-roles" header. +This header should probably get renamed to "X-assumed-roles" or "X-hsadmin-ng-rbac-assumed-roles". diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java index 90417809..58d1ce4c 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRbacEntity.java @@ -26,6 +26,7 @@ import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Permission.UPDAT import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.ADMIN; import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.AGENT; import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.REFERRER; import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.TENANT; import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL.directlyFetchedByDependsOnColumn; import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL.fetchedBySql; @@ -72,9 +73,13 @@ public class HsBookingProjectRbacEntity extends HsBookingProject { .createSubRole(ADMIN, (with) -> { with.permission(UPDATE); }) - .createSubRole(AGENT) + .createSubRole(AGENT) // just for manual grants .createSubRole(TENANT, (with) -> { with.outgoingSubRole("debitorRel", TENANT); + }) + .createSubRole(REFERRER, (with) -> { + // make the project visible for debitors, but for anything below, the owner role needs to be assumed + with.incomingSuperRole("debitorRel", AGENT); with.permission(SELECT); }) diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md index 3f4204f3..4fa2acb5 100644 --- a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md @@ -31,6 +31,7 @@ subgraph project["`**project**`"] role:project:ADMIN[[project:ADMIN]] role:project:AGENT[[project:AGENT]] role:project:TENANT[[project:TENANT]] + role:project:REFERRER[[project:REFERRER]] end subgraph project:permissions[ ] @@ -53,11 +54,13 @@ role:project:OWNER ==> role:project:ADMIN role:project:ADMIN ==> role:project:AGENT role:project:AGENT ==> role:project:TENANT role:project:TENANT ==> role:debitorRel:TENANT +role:project:TENANT ==> role:project:REFERRER +role:debitorRel:AGENT ==> role:project:REFERRER %% granting permissions to roles role:debitorRel:ADMIN ==> perm:project:INSERT role:rbac.global:ADMIN ==> perm:project:DELETE role:project:ADMIN ==> perm:project:UPDATE -role:project:TENANT ==> perm:project:SELECT +role:project:REFERRER ==> perm:project:SELECT ``` diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql index a1de8e1c..2b0c21ce 100644 --- a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql @@ -65,11 +65,18 @@ begin perform rbac.defineRoleWithGrants( hs_booking.project_TENANT(NEW), - permissions => array['SELECT'], incomingSuperRoles => array[hs_booking.project_AGENT(NEW)], outgoingSubRoles => array[hs_office.relation_TENANT(newDebitorRel)] ); + perform rbac.defineRoleWithGrants( + hs_booking.project_REFERRER(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[ + hs_booking.project_TENANT(NEW), + hs_office.relation_AGENT(newDebitorRel)] + ); + call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'DELETE'), rbac.global_ADMIN()); call rbac.leaveTriggerForObjectUuid(NEW.uuid); @@ -194,15 +201,15 @@ call rbac.generateRbacIdentityViewFromQuery('hs_booking.project', -- ============================================================================ --changeset RbacRestrictedViewGenerator:hs-booking-project-rbac-RESTRICTED-VIEW runOnChange:true validCheckSum:ANY endDelimiter:--// -- ---------------------------------------------------------------------------- --- trigger change of change in generateRbacRestrictedView regarding #453 optimization for global:ADMIN call rbac.generateRbacRestrictedView('hs_booking.project', $orderBy$ caption - $orderBy$, +$orderBy$, $updates$ version = new.version, caption = new.caption - $updates$); +$updates$ +); --// diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java index 3d604648..e87ca433 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java @@ -9,6 +9,8 @@ import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -185,6 +187,36 @@ class HsBookingProjectControllerAcceptanceTest extends ContextBasedTestWithClean } """)); // @formatter:on } + + @ParameterizedTest + @ValueSource(strings = { + "hs_office.relation#FirstGmbH-with-DEBITOR-FirstGmbH:ADMIN", + "hs_booking.project#D-1000111-D-1000111defaultproject:OWNER", + "" // without any assumed-roles + }) + void debitorAdminUser_canGetRelatedBookingProjectEvenWithoutAssumingTheProjectRole(final String assumedRoles) { + context.define("superuser-alex@hostsharing.net"); + final var debitorUuid = debitorRepo.findByDebitorNumber(1000111).stream() + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("Authorization", bearer("person-FirstGmbH@example.com")) + .header("assumed-roles", assumedRoles) + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects?debitorUuid=" + debitorUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "caption": "D-1000111 default project" + } + ] + """)); // @formatter:on + } } @Nested