From 322736cd0118ef0ef551d57da50cf1ade25c3fdc Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Sat, 13 Aug 2022 16:47:36 +0200 Subject: [PATCH] creating and viewing grants --- doc/rbac.md | 49 ++++- .../rbac/rbacgrant/RbacGrantController.java | 64 +++++++ .../rbac/rbacgrant/RbacGrantEntity.java | 60 +++++++ .../hsadminng/rbac/rbacgrant/RbacGrantId.java | 17 ++ .../rbac/rbacgrant/RbacGrantRepository.java | 13 ++ .../rbac/rbacrole/RbacRoleController.java | 6 +- .../rbac/rbacuser/RbacUserRepository.java | 3 + .../api-definition/rbac-grant-schemas.yaml | 21 +++ .../resources/api-definition/rbac-grants.yaml | 39 ++++ .../db/changelog/2022-07-28-005-rbac-base.sql | 104 ++++++++--- .../changelog/2022-07-28-007-rbac-views.sql | 66 +++++++ .../2022-07-29-061-hs-customer-rbac.sql | 2 +- .../net/hostsharing/hsadminng/Accepts.java | 13 ++ .../PackageRepositoryIntegrationTest.java | 4 +- .../RbacGrantControllerAcceptanceTest.java | 168 ++++++++++++++++++ .../RbacGrantRepositoryIntegrationTest.java | 142 +++++++++++++++ .../RbacRoleControllerAcceptanceTest.java | 60 +++++++ .../RbacUserControllerAcceptanceTest.java | 10 +- .../RbacUserRepositoryIntegrationTest.java | 1 + .../java/net/hostsharing/test/JpaAttempt.java | 7 + 20 files changed, 817 insertions(+), 32 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java create mode 100644 src/main/resources/api-definition/rbac-grant-schemas.yaml create mode 100644 src/main/resources/api-definition/rbac-grants.yaml create mode 100644 src/test/java/net/hostsharing/hsadminng/Accepts.java create mode 100644 src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java diff --git a/doc/rbac.md b/doc/rbac.md index 83a02241..d8769a1a 100644 --- a/doc/rbac.md +++ b/doc/rbac.md @@ -461,12 +461,12 @@ actorHostmaster --> roleAdmins 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 @@ The WHERE-IN-variant is about 50% slower on the smaller dataset, but almost keep 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. + diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java new file mode 100644 index 00000000..ba299613 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java @@ -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> 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 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(); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java new file mode 100644 index 00000000..53fc44ac --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java @@ -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 " : "") + + ")"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java new file mode 100644 index 00000000..d3bcc4ae --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java @@ -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; +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java new file mode 100644 index 00000000..655f6216 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepository.java @@ -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 { + + List findAll(); + + void save(final RbacGrantEntity grant); + +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java index df3dfcf7..86a16aed 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleController.java @@ -24,11 +24,15 @@ public class RbacRoleController implements RbacrolesApi { @Override @Transactional - public ResponseEntity> listRoles(final String currentUser, final String assumedRoles) { + public ResponseEntity> 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)); } + } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java index c43c611e..ec1c9da2 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepository.java @@ -17,6 +17,9 @@ public interface RbacUserRepository extends Repository { """) List 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) diff --git a/src/main/resources/api-definition/rbac-grant-schemas.yaml b/src/main/resources/api-definition/rbac-grant-schemas.yaml new file mode 100644 index 00000000..46140f1d --- /dev/null +++ b/src/main/resources/api-definition/rbac-grant-schemas.yaml @@ -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 diff --git a/src/main/resources/api-definition/rbac-grants.yaml b/src/main/resources/api-definition/rbac-grants.yaml new file mode 100644 index 00000000..fa4d14da --- /dev/null +++ b/src/main/resources/api-definition/rbac-grants.yaml @@ -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' diff --git a/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql b/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql index 001237a7..0e233f8c 100644 --- a/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql +++ b/src/main/resources/db/changelog/2022-07-28-005-rbac-base.sql @@ -66,7 +66,7 @@ create or replace function createRbacUser(refUuid uuid, userName varchar) 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 @@ begin 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 @@ declare 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 @@ create or replace function hasGlobalRoleGranted(userUuid uuid) 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 @@ begin 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 @@ begin 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 @@ begin 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 @@ begin 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 @@ create or replace function queryPermissionsGrantedToSubjectId(subjectId uuid) returns setof RbacPermission strict language sql as $$ --- @formatter:off + -- @formatter:off select * from RbacPermission where uuid in ( diff --git a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql b/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql index 415dcc89..2cb82e58 100644 --- a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql +++ b/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql @@ -24,6 +24,72 @@ grant all privileges on rbacrole_rv to restricted; --// +-- ============================================================================ +--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:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql b/src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql index 18a4b659..73654484 100644 --- a/src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql +++ b/src/main/resources/db/changelog/2022-07-29-061-hs-customer-rbac.sql @@ -76,7 +76,7 @@ begin 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 ); diff --git a/src/test/java/net/hostsharing/hsadminng/Accepts.java b/src/test/java/net/hostsharing/hsadminng/Accepts.java new file mode 100644 index 00000000..fbbd34bb --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/Accepts.java @@ -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(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java index 5fb315f9..301ca632 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java @@ -32,7 +32,7 @@ class PackageRepositoryIntegrationTest { class FindAllByOptionalNameLike { @Test - public void hostsharingAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotFollowed() { + public void hostsharingAdmin_withoutAssumedRole_canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() { // given currentUser("mike@hostsharing.net"); @@ -44,7 +44,7 @@ class PackageRepositoryIntegrationTest { } @Test - public void hostsharingAdmin_withAssumedHostsharingAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotFollowed() { + public void hostsharingAdmin_withAssumedHostsharingAdminRole__canNotViewAnyPackages_becauseThoseGrantsAreNotassumedd() { given: currentUser("mike@hostsharing.net"); assumedRoles("global#hostsharing.admin"); diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java new file mode 100644 index 00000000..2d685a88 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java @@ -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 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(); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java new file mode 100644 index 00000000..82832518 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java @@ -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 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); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java new file mode 100644 index 00000000..257d23bb --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleControllerAcceptanceTest.java @@ -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 + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java index 51ae2861..c18302e5 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserControllerAcceptanceTest.java @@ -15,8 +15,7 @@ import javax.transaction.Transactional; 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 @@ class RbacUserControllerAcceptanceTest { .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 } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java index 3f508820..182b9c5e 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java @@ -371,6 +371,7 @@ class RbacUserRepositoryIntegrationTest { void exactlyTheseRbacUsersAreReturned(final List actualResult, final String... expectedUserNames) { assertThat(actualResult) + .filteredOn(u -> !u.getName().startsWith("test-user-")) .extracting(RbacUserEntity::getName) .containsExactlyInAnyOrder(expectedUserNames); } diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/test/JpaAttempt.java index 94b2eb14..a0720367 100644 --- a/src/test/java/net/hostsharing/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/test/JpaAttempt.java @@ -49,6 +49,13 @@ public class JpaAttempt { } } + public static JpaResult attempt(final EntityManager em, final Runnable code) { + return attempt(em, () -> { + code.run(); + return null; + }); + } + @Transactional(propagation = Propagation.REQUIRES_NEW) public JpaResult transacted(final Supplier code) { return attempt(em, code);