Michael Hoennig
2022-08-13 322736cd0118ef0ef551d57da50cf1ade25c3fdc
creating and viewing grants
10 files added
10 files modified
849 ■■■■■ changed files
doc/rbac.md 49 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java 64 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java 60 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java 17 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java 13 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java 6 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java 3 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/rbac-grant-schemas.yaml 21 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/rbac-grants.yaml 39 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql 104 ●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql 66 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql 2 ●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/Accepts.java 13 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java 4 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java 168 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java 142 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java 60 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java 10 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java 1 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/test/JpaAttempt.java 7 ●●●●● patch | view | raw | blame | history
doc/rbac.md
@@ -461,12 +461,12 @@
As you can see, there something special:
From the 'Role customer#xyz.owner' to the 'Role customer#xyz.admin' there is a dashed line, whereas all other lines are solid lines.
Solid lines means, that one role is granted to another and followed in all queries to the restricted views.
The dashed line means that one role is granted to another but not automatically followed in queries to the restricted views.
Solid lines means, that one role is granted to another and automatically assumed in all queries to the restricted views.
The dashed line means that one role is granted to another but not automatically assumed in queries to the restricted views.
The reason here is that otherwise simply too many objects would be accessible to those with the 'administrators' role and all queries would be slowed down vastly.
Grants which are not followed are still valid grants for `hsadminng.assumedRoles`.
Grants which are not automatically assumed are still valid grants for `hsadminng.assumedRoles`.
Thus, if you want to access anything below a customer, assume its role first.
There is actually another speciality in the customer roles:
@@ -632,4 +632,47 @@
Both variants a viable option, depending on other needs, e.g. updatable views. 
## Access Control to RBAC-Objects
Access Control for business objects checked according to the assigned roles.
But we decided not to create such roles and permissions for the RBAC-Objects itself.
It would have overcomplicated the system and the necessary information can easily be added to the RBAC-Objects itself, mostly the `RbacGrant`s.
### RbacUser
Users can self-register, thus to create a new RbacUser entity, no login is required.
But such a user has no access-rights except viewing itself.
Users can view themselves.
And any user can view all other users as long as they have the same roles assigned.
As an exception, users which are assigned to global roles are not visible by other users.
At least an indirect lookup of known user-names (e.g. email address of the user) is possible
by users who have an empowered assignment of any role.
Otherwise, it would not be possible to assign roles to new users.
### RbacRole
All roles are system-defined and cannot be created or modified by any external API.
Users can view only the roles to which they are assigned.
## RbacGrant
Grant can be `empowered`, this means that the grantee user can grant the granted role to other users
and revoke grants to that role.
(TODO: access control part not yet implemented)
Grants can be `managed`, which means they are created and deleted by system-defined rules.
If a grant is not managed, it was created by an empowered user and can be deleted by empowered users.
Grants can be `assumed`, which means that they are immediately active.
If a grant is not assumed, the grantee user needs to use `assumeRoles` to activate it.
Users can see only grants of roles to which they are (directly?) assigned themselves.
TODO: If a user grants an indirect role to another user, that grant would not be visible to the user.
But if we make indirect grants visible, this would reveal too much information.
We also cannot keep the granting user in the grant because grants must survive deleted users,
e.g. if after an account was transferred to another user.
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java
New file
@@ -0,0 +1,64 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.generated.api.v1.api.RbacgrantsApi;
import net.hostsharing.hsadminng.generated.api.v1.api.RbacrolesApi;
import net.hostsharing.hsadminng.generated.api.v1.model.RbacGrantResource;
import net.hostsharing.hsadminng.generated.api.v1.model.RbacRoleResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import javax.transaction.Transactional;
import java.util.List;
import static net.hostsharing.hsadminng.Mapper.map;
import static net.hostsharing.hsadminng.Mapper.mapList;
@RestController
public class RbacGrantController implements RbacgrantsApi {
    @Autowired
    private Context context;
    @Autowired
    private RbacGrantRepository rbacGrantRepository;
    @Override
    @Transactional
    public ResponseEntity<List<RbacGrantResource>> listUserGrants(
        final String currentUser,
        final String assumedRoles) {
        context.setCurrentUser(currentUser);
        if (assumedRoles != null && !assumedRoles.isBlank()) {
            context.assumeRoles(assumedRoles);
        }
        return ResponseEntity.ok(mapList(rbacGrantRepository.findAll(), RbacGrantResource.class));
    }
    @Override
    @Transactional
    public ResponseEntity<Void> grantRoleToUser(
        final String currentUser,
        final String assumedRoles,
        final RbacGrantResource body) {
        context.setCurrentUser(currentUser);
        if (assumedRoles != null && !assumedRoles.isBlank()) {
            context.assumeRoles(assumedRoles);
        }
        rbacGrantRepository.save(map(body, RbacGrantEntity.class));
        final var uri =
            MvcUriComponentsBuilder.fromController(getClass())
                .path("/api/rbac-grants/{roleUuid}")
                .buildAndExpand(body.getRoleUuid())
                .toUri();
        return ResponseEntity.created(uri).build();
    }
}
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java
New file
@@ -0,0 +1,60 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import lombok.*;
import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleType;
import org.springframework.data.annotation.Immutable;
import javax.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "rbacgrants_rv")
@IdClass(RbacGrantId.class)
@Getter
@Setter
@Builder
@ToString
@Immutable
@NoArgsConstructor
@AllArgsConstructor
public class RbacGrantEntity {
    @Column(name = "username", updatable = false, insertable = false)
    private String userName;
    @Column(name = "roleidname", updatable = false, insertable = false)
    private String roleIdName;
    private boolean managed;
    private boolean assumed;
    private boolean empowered;
    @Id
    @Column(name = "useruuid")
    private UUID userUuid;
    @Id
    @Column(name = "roleuuid")
    private UUID roleUuid;
    @Column(name = "objecttable", updatable = false, insertable = false)
    private String objectTable;
    @Column(name = "objectuuid", updatable = false, insertable = false)
    private UUID objectUuid;
    @Column(name = "objectidname", updatable = false, insertable = false)
    private String objectIdName;
    @Column(name = "roletype", updatable = false, insertable = false)
    @Enumerated(EnumType.STRING)
    private RbacRoleType roleType;
    public String toDisplay() {
        return "grant( " + userName + " -> " + roleIdName + ": " +
            (managed ? "managed " : "") +
            (assumed ? "assumed " : "") +
            (empowered ? "empowered " : "") +
            ")";
    }
}
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java
New file
@@ -0,0 +1,17 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.UUID;
@Getter
@EqualsAndHashCode
@NoArgsConstructor
public class RbacGrantId implements Serializable {
    private UUID userUuid;
    private UUID roleUuid;
}
src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java
New file
@@ -0,0 +1,13 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface RbacGrantRepository extends Repository<RbacGrantEntity, RbacGrantId> {
    List<RbacGrantEntity> findAll();
    void save(final RbacGrantEntity grant);
}
src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java
@@ -24,11 +24,15 @@
    @Override
    @Transactional
    public ResponseEntity<List<RbacRoleResource>> listRoles(final String currentUser, final String assumedRoles) {
    public ResponseEntity<List<RbacRoleResource>> listRoles(
        final String currentUser,
        final String assumedRoles) {
        context.setCurrentUser(currentUser);
        if (assumedRoles != null && !assumedRoles.isBlank()) {
            context.assumeRoles(assumedRoles);
        }
        return ResponseEntity.ok(mapList(rbacRoleRepository.findAll(), RbacRoleResource.class));
    }
}
src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java
@@ -17,6 +17,9 @@
        """)
    List<RbacUserEntity> findByOptionalNameLike(String userName);
    @Query(value = "select uuid from rbacuser where name=:userName", nativeQuery = true)
    UUID findUuidByName(String userName);
    RbacUserEntity findByUuid(UUID uuid);
    @Query(value = "select * from grantedPermissions(:userName)", nativeQuery = true)
src/main/resources/api-definition/rbac-grant-schemas.yaml
New file
@@ -0,0 +1,21 @@
components:
    schemas:
        RbacGrant:
            type: object
            properties:
                userUuid:
                    type: string
                    format: uuid
                roleUuid:
                    type: string
                    format: uuid
                assumed:
                    type: boolean
                empowered:
                    type: boolean
            required:
                - userUuid
                - roleUuid
src/main/resources/api-definition/rbac-grants.yaml
New file
@@ -0,0 +1,39 @@
get:
    tags:
        - rbacgrants
    operationId: listUserGrants
    parameters:
        - $ref: './api-definition/auth.yaml#/components/parameters/currentUser'
        - $ref: './api-definition/auth.yaml#/components/parameters/assumedRoles'
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        type: array
                        items:
                            $ref: './api-definition/rbac-grant-schemas.yaml#/components/schemas/RbacGrant'
post:
    tags:
        - rbacgrants
    operationId: grantRoleToUser
    parameters:
        - $ref: './api-definition/auth.yaml#/components/parameters/currentUser'
        - $ref: './api-definition/auth.yaml#/components/parameters/assumedRoles'
    requestBody:
        required: true
        content:
            application/json:
                schema:
                    $ref: './api-definition/rbac-grant-schemas.yaml#/components/schemas/RbacGrant'
    responses:
        "201":
            description: OK
        "401":
            $ref: './api-definition/error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './api-definition/error-responses.yaml#/components/responses/Forbidden'
        "409":
            $ref: './api-definition/error-responses.yaml#/components/responses/Conflict'
src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql
@@ -66,7 +66,7 @@
begin
    insert
        into RbacReference as r (uuid, type)
        values ( coalesce(refUuid, uuid_generate_v4()), 'RbacUser')
        values (coalesce(refUuid, uuid_generate_v4()), 'RbacUser')
        returning r.uuid into refUuid;
    insert
        into RbacUser (uuid, name)
@@ -206,6 +206,33 @@
end;
$$;
create or replace function findRoleId(roleIdName varchar)
    returns uuid
    returns null on null input
    language plpgsql as $$
declare
    roleParts                 text;
    roleTypeFromRoleIdName    RbacRoleType;
    objectNameFromRoleIdName  text;
    objectTableFromRoleIdName text;
    objectUuidOfRole          uuid;
    roleUuid                  uuid;
begin
    -- TODO: extract function toRbacRoleDescriptor(roleIdName varchar) + find other occurrences
    roleParts = overlay(roleIdName placing '#' from length(roleIdName) + 1 - strpos(reverse(roleIdName), '.'));
    objectTableFromRoleIdName = split_part(roleParts, '#', 1);
    objectNameFromRoleIdName = split_part(roleParts, '#', 2);
    roleTypeFromRoleIdName = split_part(roleParts, '#', 3);
    objectUuidOfRole = findObjectUuidByIdName(objectTableFromRoleIdName, objectNameFromRoleIdName);
    select uuid
        from RbacRole
        where objectUuid = objectUuidOfRole
          and roleType = roleTypeFromRoleIdName
        into roleUuid;
    return roleUuid;
end; $$;
create or replace function findRoleId(roleDescriptor RbacRoleDescriptor)
    returns uuid
    returns null on null input
@@ -322,13 +349,15 @@
--changeset rbac-base-GRANTS:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
    Table to store grants / role- or permission assignments to users or roles.
 */
create table RbacGrants
(
    ascendantUuid  uuid references RbacReference (uuid) on delete cascade,
    descendantUuid uuid references RbacReference (uuid) on delete cascade,
    follow         boolean not null default true,
    managed        boolean not null default false, -- created by system (true) vs. user (false)
    assumed        boolean not null default true,  -- auto assumed (true) vs. needs assumeRoles (false)
    empowered      boolean not null default false, -- true: allows grant+revoke for descendant role
    primary key (ascendantUuid, descendantUuid)
);
create index on RbacGrants (ascendantUuid);
@@ -377,7 +406,8 @@
    granteeId uuid;
begin
    -- TODO: needs optimization
    foreach granteeId in array granteeIds loop
    foreach granteeId in array granteeIds
        loop
            if isGranted(granteeId, grantedId) then
                return true;
            end if;
@@ -413,10 +443,11 @@
    language sql as $$
select exists(
           select r.uuid
                from RbacGrants as g
                join RbacRole as r on r.uuid = g.descendantuuid
                join RbacObject as o on o.uuid = r.objectuuid
                where g.ascendantuuid = userUuid and o.objecttable = 'global'
               from RbacGrants as g
                        join RbacRole as r on r.uuid = g.descendantuuid
                        join RbacObject as o on o.uuid = r.objectuuid
               where g.ascendantuuid = userUuid
                 and o.objecttable = 'global'
           );
$$;
@@ -432,14 +463,14 @@
            perform assertReferenceType('permissionId (descendant)', permissionIds[i], 'RbacPermission');
            insert
                into RbacGrants (ascendantUuid, descendantUuid, follow)
                values (roleUuid, permissionIds[i], true)
                into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered)
                values (roleUuid, permissionIds[i], true, true, false)
            on conflict do nothing; -- allow granting multiple times
        end loop;
end;
$$;
create or replace procedure grantRoleToRole(subRoleId uuid, superRoleId uuid, doFollow bool = true)
create or replace procedure grantRoleToRole(subRoleId uuid, superRoleId uuid, doAssume bool = true)
    language plpgsql as $$
begin
    perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole');
@@ -450,8 +481,8 @@
    end if;
    insert
        into RbacGrants (ascendantUuid, descendantUuid, follow)
        values (superRoleId, subRoleId, doFollow)
        into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered)
        values (superRoleId, subRoleId, true, doAssume, false)
    on conflict do nothing; -- allow granting multiple times
end; $$;
@@ -466,16 +497,45 @@
    end if;
end; $$;
create or replace procedure grantRoleToUser(roleId uuid, userId uuid)
create or replace procedure grantRoleToUser(roleUuid uuid, userUuid uuid)
    language plpgsql as $$
begin
    perform assertReferenceType('roleId (ascendant)', roleId, 'RbacRole');
    perform assertReferenceType('userId (descendant)', userId, 'RbacUser');
    perform assertReferenceType('roleId (descendant)', roleUuid, 'RbacRole');
    perform assertReferenceType('userId (ascendant)', userUuid, 'RbacUser');
    insert
        into RbacGrants (ascendantUuid, descendantUuid, follow)
        values (userId, roleId, true)
    on conflict do nothing; -- allow granting multiple times
        into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered)
        values (userUuid, roleUuid, true, true, true);
    -- TODO: What should happen on mupltiple grants? What if options are not the same?
    -- on conflict do nothing; -- allow granting multiple times
end; $$;
/*
    Attributes of a grant assignment.
 */
create type RbacGrantOptions as
(
    managed   boolean, -- created by system (true) vs. user (false)
    assumed   boolean, -- auto assumed (true) vs. needs assumeRoles (false)
    empowered boolean  -- true: allows grant+revoke for descendant role
);
create or replace procedure grantRoleToUser(roleUuid uuid, userUuid uuid, grantOptions RbacGrantOptions)
    language plpgsql as $$
begin
    perform assertReferenceType('roleId (descendant)', roleUuid, 'RbacRole');
    perform assertReferenceType('userId (ascendant)', userUuid, 'RbacUser');
    if not isGranted(currentSubjectIds(), roleUuid) then
        raise exception '[403] Access to role uuid % forbidden for %', roleUuid, currentSubjects();
    end if;
    insert
        into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered)
        values (userUuid, roleUuid, grantOptions.managed, grantOptions.assumed, grantOptions.empowered);
    -- TODO: What should happen on mupltiple grants? What if options are not the same?
    --      Most powerful or latest grant wins? What about managed?
    -- on conflict do nothing; -- allow granting multiple times
end; $$;
--//
@@ -499,14 +559,14 @@
    return query select distinct perm.objectUuid
                     from (with recursive grants as (select descendantUuid, ascendantUuid, 1 as level
                                                         from RbacGrants
                                                         where follow
                                                         where assumed
                                                           and ascendantUuid = any (subjectIds)
                                                     union
                                                     distinct
                                                     select "grant".descendantUuid, "grant".ascendantUuid, level + 1 as level
                                                         from RbacGrants "grant"
                                                                  inner join grants recur on recur.descendantUuid = "grant".ascendantUuid
                                                         where follow)
                                                         where assumed)
                           select descendantUuid
                               from grants) as granted
                              join RbacPermission perm
@@ -536,7 +596,7 @@
    returns setof RbacPermission
    strict
    language sql as $$
-- @formatter:off
    -- @formatter:off
select *
    from RbacPermission
    where uuid in (
src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql
@@ -25,6 +25,72 @@
-- ============================================================================
--changeset rbac-views-GRANT-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
    Creates a view to the grants table with row-level limitation
    based on the direct grants of the current user.
 */
drop view if exists rbacgrants_rv;
create or replace view rbacgrants_rv as
select userName, objectTable||'#'||objectIdName||'.'||roletype as roleIdName,
       managed, assumed, empowered,
       ascendantUuid as userUuid,
       descendantUuid as roleUuid,
       objectTable, objectUuid, objectIdName, roleType
       -- @formatter:off
    from (
             select g.*, u.name as userName, o.objecttable, r.objectuuid, r.roletype,
                    findIdNameByObjectUuid(o.objectTable, o.uuid) as objectIdName
                 from rbacgrants as g
                 join rbacrole as r on r.uuid = g.descendantUuid
                 join rbacobject o on o.uuid = r.objectuuid
                 join rbacuser u on u.uuid = g.ascendantuuid
                 where isGranted(currentSubjectIds(), r.uuid)
         ) as unordered
         -- @formatter:on
    order by objectIdName;
grant all privileges on rbacrole_rv to restricted;
--//
-- ============================================================================
--changeset rbac-views-GRANTS-RV-INSERT-TRIGGER:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/**
    Instead of insert trigger function for RbacGrants_RV.
 */
create or replace function insertRbacGrant()
    returns trigger
    language plpgsql as $$
declare
    newGrant RbacGrants_RV;
begin
    if new.managed then
        raise exception '[400] Managed grants cannot be inserted via RBacGrants_RV.';
    end if;
    call grantRoleToUser(new.roleUuid, new.userUuid,
        ROW(false, new.assumed, new.empowered));
    select grv.*
        from RbacGrants_RV grv
        where grv.userUuid=new.userUuid and grv.roleUuid=new.roleUuid
        into newGrant;
    return newGrant;
end; $$;
/*
    Creates an instead of insert trigger for the RbacGrants_rv view.
 */
create trigger insertRbacGrant_Trigger
    instead of insert
    on RbacGrants_rv
    for each row
execute function insertRbacGrant();
-- ============================================================================
--changeset rbac-views-USER-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql
@@ -76,7 +76,7 @@
    customerAdminUuid = createRole(
        customerAdmin(NEW),
        grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view', 'add-package']),
        -- NO auto follow for customer owner to avoid exploding permissions for administrators
        -- NO auto assume for customer owner to avoid exploding permissions for administrators
        withUser(NEW.adminUserName, 'create') -- implicitly ignored if null
        );
src/test/java/net/hostsharing/hsadminng/Accepts.java
New file
@@ -0,0 +1,13 @@
package net.hostsharing.hsadminng;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
public @interface Accepts {
    String[] value();
}
src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java
@@ -32,7 +32,7 @@
    class FindAllByOptionalNameLike {
        @Test
        public void hostsharingAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotFollowed() {
        public void hostsharingAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() {
            // given
            currentUser("mike@hostsharing.net");
@@ -44,7 +44,7 @@
        }
        @Test
        public void hostsharingAdmin_withAssumedHostsharingAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotFollowed() {
        public void hostsharingAdmin_withAssumedHostsharingAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() {
            given:
            currentUser("mike@hostsharing.net");
            assumedRoles("global#hostsharing.admin");
src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java
New file
@@ -0,0 +1,168 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.Accepts;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleEntity;
import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository;
import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserEntity;
import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository;
import net.hostsharing.test.JpaAttempt;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.is;
@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    classes = { HsadminNgApplication.class, JpaAttempt.class }
)
@Accepts({ "ROL:S(Schema)" })
class RbacGrantControllerAcceptanceTest {
    @LocalServerPort
    Integer port;
    @Autowired
    EntityManager em;
    @Autowired
    Context context;
    @Autowired
    RbacUserRepository rbacUserRepository;
    @Autowired
    RbacRoleRepository rbacRoleRepository;
    @Autowired
    RbacGrantRepository rbacGrantRepository;
    @Autowired
    JpaAttempt jpaAttempt;
    @Test
    @Accepts({ "ROL:L(List)" })
    void returnsRbacGrantsForPackageAdmin() {
        RestAssured // @formatter:off
            .given()
                .header("current-user", "aaa00@aaa.example.com")
                .port(port)
            .when()
                .get("http://localhost/api/rbac-roles")
            .then().assertThat()
                .statusCode(200)
                .contentType("application/json")
                .body("[0].roleName", is("customer#aaa.tenant"))
                .body("[1].roleName", is("package#aaa00.admin"))
                .body("[2].roleName", is("package#aaa00.tenant"));
            // @formatter:on
    }
    @Test
    @Accepts({ "ROL:C(Create)" })
    void packageAdmin_canGrantOwnPackageAdminRole_toArbitraryUser() {
        // given
        final var givenNewUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com";
        final String givenPackageAdmin = "aaa00@aaa.example.com";
        final var givenOwnPackageAdminRole = "package#aaa00.admin";
        // when
        RestAssured // @formatter:off
            .given()
                .header("current-user", givenPackageAdmin)
                .contentType(ContentType.JSON)
                .body("""
                      {
                        "userUuid": "%s",
                        "roleUuid": "%s",
                        "assumed": true,
                        "empowered": false
                      }
                      """.formatted(
                        createRBacUser(givenNewUserName).getUuid().toString(),
                        findRbacRoleByName(givenOwnPackageAdminRole).getUuid().toString())
                )
                .port(port)
            .when()
                .post("http://localhost/api/rbac-grants")
            .then().assertThat()
                .statusCode(201);
            // @formatter:on
        // then
        assertThat(findAllGrantsOfUser(givenPackageAdmin))
            .extracting(RbacGrantEntity::toDisplay)
            .contains("grant( " + givenNewUserName + " -> " + givenOwnPackageAdminRole + ": assumed )");
    }
    @Test
    @Accepts({ "ROL:C(Create)", "ROL:X(Access Control)" })
    void packageAdmin_canNotGrantAlienPackageAdminRole_toArbitraryUser() {
        // given
        final var givenNewUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com";
        final String givenPackageAdmin = "aaa00@aaa.example.com";
        final var givenAlienPackageAdminRole = "package#aab00.admin";
        // when
        RestAssured // @formatter:off
            .given()
            .header("current-user", givenPackageAdmin)
            .contentType(ContentType.JSON)
            .body("""
                      {
                        "userUuid": "%s",
                        "roleUuid": "%s",
                        "assumed": true,
                        "empowered": false
                      }
                      """.formatted(
                createRBacUser(givenNewUserName).getUuid().toString(),
                findRbacRoleByName(givenAlienPackageAdminRole).getUuid().toString())
            )
            .port(port)
            .when()
            .post("http://localhost/api/rbac-grants")
            .then().assertThat()
            .statusCode(403);
            // @formatter:on
        // then
        assertThat(findAllGrantsOfUser(givenPackageAdmin))
            .extracting(RbacGrantEntity::getUserName)
            .doesNotContain(givenNewUserName);
    }
    List<RbacGrantEntity> findAllGrantsOfUser(final String userName) {
        return jpaAttempt.transacted(() -> {
            context.setCurrentUser(userName);
            return rbacGrantRepository.findAll();
        }).returnedValue();
    }
    RbacUserEntity createRBacUser(final String userName) {
        return jpaAttempt.transacted(() -> {
            return rbacUserRepository.create(new RbacUserEntity(UUID.randomUUID(), userName));
        }).returnedValue();
    }
    RbacRoleEntity findRbacRoleByName(final String roleName) {
        return jpaAttempt.transacted(() -> {
            context.setCurrentUser("mike@hostsharing.net");
            return rbacRoleRepository.findByRoleName(roleName);
        }).returnedValue();
    }
}
src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java
New file
@@ -0,0 +1,142 @@
package net.hostsharing.hsadminng.rbac.rbacgrant;
import net.hostsharing.hsadminng.Accepts;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository;
import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository;
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.context.annotation.ComponentScan;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.test.annotation.DirtiesContext;
import javax.persistence.EntityManager;
import java.util.List;
import static net.hostsharing.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ComponentScan(basePackageClasses = { Context.class, RbacGrantRepository.class })
@DirtiesContext
@Accepts({ "GRT:S(Schema)" })
class RbacGrantRepositoryIntegrationTest {
    @Autowired
    Context context;
    @Autowired
    RbacGrantRepository rbacGrantRepository;
    @Autowired
    RbacUserRepository rbacUserRepository;
    @Autowired
    RbacRoleRepository rbacRoleRepository;
    @Autowired
    EntityManager em;
    @Nested
    class FindAllRbacGrants {
        @Test
        @Accepts({ "GRT:L(List)" })
        public void packageAdmin_canViewItsRbacGrants() {
            // given
            currentUser("aaa00@aaa.example.com");
            // when
            final var result = rbacGrantRepository.findAll();
            // then
            exactlyTheseRbacGrantsAreReturned(
                result,
                "grant( aaa00@aaa.example.com -> package#aaa00.admin: managed assumed empowered )");
        }
        @Test
        @Accepts({ "GRT:L(List)" })
        public void customerAdmin_canViewItsRbacGrants() {
            // given
            currentUser("admin@aaa.example.com");
            // when
            final var result = rbacGrantRepository.findAll();
            // then
            exactlyTheseRbacGrantsAreReturned(
                result,
                "grant( admin@aaa.example.com -> customer#aaa.admin: managed assumed empowered )",
                "grant( aaa00@aaa.example.com -> package#aaa00.admin: managed assumed empowered )",
                "grant( aaa01@aaa.example.com -> package#aaa01.admin: managed assumed empowered )",
                "grant( aaa02@aaa.example.com -> package#aaa02.admin: managed assumed empowered )");
        }
        @Test
        @Accepts({ "GRT:L(List)" })
        public void customerAdmin_withAssumedRole_cannotViewRbacGrants() {
            // given:
            currentUser("admin@aaa.example.com");
            assumedRoles("package#aab00.admin");
            // when
            final var result = attempt(
                em,
                () -> rbacGrantRepository.findAll());
            // then
            result.assertExceptionWithRootCauseMessage(
                JpaSystemException.class,
                "[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin");
        }
    }
    @Nested
    class CreateRbacGrant {
        @Test
        @Accepts({ "GRT:C(Create)" })
        public void customerAdmin_canGrantOwnPackageAdminRole_toArbitraryUser() {
            // given
            currentUser("admin@aaa.example.com");
            final var userUuid = rbacUserRepository.findUuidByName("aac00@aac.example.com");
            final var roleUuid = rbacRoleRepository.findByRoleName("package#aaa00.admin").getUuid();
            // when
            final var grant = RbacGrantEntity.builder()
                .userUuid(userUuid).roleUuid(roleUuid)
                .assumed(true).empowered(false)
                .build();
            final var attempt = attempt(em, () ->
                rbacGrantRepository.save(grant)
            );
            // then
            assertThat(attempt.wasSuccessful()).isTrue();
            assertThat(rbacGrantRepository.findAll())
                .extracting(RbacGrantEntity::toDisplay)
                .contains("grant( aac00@aac.example.com -> package#aaa00.admin: assumed )");
        }
    }
    void currentUser(final String currentUser) {
        context.setCurrentUser(currentUser);
        assertThat(context.getCurrentUser()).as("precondition").isEqualTo(currentUser);
    }
    void assumedRoles(final String assumedRoles) {
        context.assumeRoles(assumedRoles);
        assertThat(context.getAssumedRoles()).as("precondition").containsExactly(assumedRoles.split(";"));
    }
    void exactlyTheseRbacGrantsAreReturned(final List<RbacGrantEntity> actualResult, final String... expectedGrant) {
        assertThat(actualResult)
            .filteredOn(g -> !g.getUserName().startsWith("test-user-")) // ignore test-users created by other tests
            .extracting(RbacGrantEntity::toDisplay)
            .containsExactlyInAnyOrder(expectedGrant);
    }
}
src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java
New file
@@ -0,0 +1,60 @@
package net.hostsharing.hsadminng.rbac.rbacrole;
import io.restassured.RestAssured;
import net.hostsharing.hsadminng.Accepts;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import javax.persistence.EntityManager;
import static org.hamcrest.Matchers.is;
@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    classes = HsadminNgApplication.class
)
@Accepts({ "ROL:*:S:Schema" })
class RbacRoleControllerAcceptanceTest {
    @LocalServerPort
    private Integer port;
    @Autowired
    EntityManager em;
    @Autowired
    Context context;
    @Autowired
    RbacUserRepository rbacUserRepository;
    @Autowired
    RbacRoleRepository rbacRoleRepository;
    @Test
    @Accepts({ "ROL:*:L:List" })
    void returnsRbacRolesForAssumedPackageAdmin() {
        // @formatter:off
        RestAssured
            .given()
                .header("current-user", "mike@hostsharing.net")
                .header("assumed-roles", "package#aaa00.admin")
                .port(port)
            .when()
                .get("http://localhost/api/rbac-roles")
            .then().assertThat()
                .statusCode(200)
                .contentType("application/json")
                .body("[0].roleName", is("customer#aaa.tenant"))
                .body("[1].roleName", is("package#aaa00.admin"))
                .body("[2].roleName", is("package#aaa00.tenant"));
        // @formatter:on
    }
}
src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java
@@ -15,8 +15,7 @@
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.hamcrest.Matchers.*;
@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
@@ -56,7 +55,12 @@
                    .body("[0].name", is("aaa00@aaa.example.com"))
                    .body("[1].name", is("aaa01@aaa.example.com"))
                    .body("[2].name", is("aaa02@aaa.example.com"))
                    .body("size()", is(14));
                    .body("[3].name", is("aab00@aab.example.com"))
                    // ...
                    .body("[11].name", is("admin@aac.example.com"))
                    .body("[12].name", is("mike@hostsharing.net"))
                    .body("[13].name", is("sven@hostsharing.net"))
                    .body("size()", greaterThanOrEqualTo(14));
            // @formatter:on
        }
src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java
@@ -371,6 +371,7 @@
    void exactlyTheseRbacUsersAreReturned(final List<RbacUserEntity> actualResult, final String... expectedUserNames) {
        assertThat(actualResult)
            .filteredOn(u -> !u.getName().startsWith("test-user-"))
            .extracting(RbacUserEntity::getName)
            .containsExactlyInAnyOrder(expectedUserNames);
    }
src/test/java/net/hostsharing/test/JpaAttempt.java
@@ -49,6 +49,13 @@
        }
    }
    public static JpaResult<Void> attempt(final EntityManager em, final Runnable code) {
        return attempt(em, () -> {
            code.run();
            return null;
        });
    }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public <T> JpaResult<T> transacted(final Supplier<T> code) {
        return attempt(em, code);