diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java index ba299613..31f59f6a 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantController.java @@ -56,7 +56,7 @@ public class RbacGrantController implements RbacgrantsApi { final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/rbac-grants/{roleUuid}") - .buildAndExpand(body.getRoleUuid()) + .buildAndExpand(body.getGrantedRoleUuid()) .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 index 53fc44ac..72d4c711 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantEntity.java @@ -18,24 +18,27 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor public class RbacGrantEntity { + @Column(name = "grantedbyroleidname", updatable = false, insertable = false) + private String grantedByRoleIdName; + + @Column(name = "grantedroleidname", updatable = false, insertable = false) + private String grantedRoleIdName; @Column(name = "username", updatable = false, insertable = false) - private String userName; + private String granteeUserName; - @Column(name = "roleidname", updatable = false, insertable = false) - private String roleIdName; - - private boolean managed; private boolean assumed; - private boolean empowered; + + @Column(name = "grantedbyroleuuid", updatable = false, insertable = false) + private UUID grantedByRoleUuid; + + @Id + @Column(name = "grantedroleuuid") + private UUID grantedRoleUuid; @Id @Column(name = "useruuid") - private UUID userUuid; - - @Id - @Column(name = "roleuuid") - private UUID roleUuid; + private UUID granteeUserUuid; @Column(name = "objecttable", updatable = false, insertable = false) private String objectTable; @@ -46,15 +49,12 @@ public class RbacGrantEntity { @Column(name = "objectidname", updatable = false, insertable = false) private String objectIdName; - @Column(name = "roletype", updatable = false, insertable = false) + @Column(name = "grantedroletype", updatable = false, insertable = false) @Enumerated(EnumType.STRING) - private RbacRoleType roleType; + private RbacRoleType grantedRoleType; public String toDisplay() { - return "grant( " + userName + " -> " + roleIdName + ": " + - (managed ? "managed " : "") + - (assumed ? "assumed " : "") + - (empowered ? "empowered " : "") + - ")"; + return "{ grant " + (assumed ? "assumed " : "") + + "role " + grantedRoleIdName + " to user " + granteeUserName + " by role " + grantedByRoleIdName + " }"; } } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java index d3bcc4ae..bc87a2b8 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantId.java @@ -12,6 +12,6 @@ import java.util.UUID; @NoArgsConstructor public class RbacGrantId implements Serializable { - private UUID userUuid; - private UUID roleUuid; + private UUID granteeUserUuid; + private UUID grantedRoleUuid; } diff --git a/src/main/resources/api-definition/rbac-grant-schemas.yaml b/src/main/resources/api-definition/rbac-grant-schemas.yaml index 46140f1d..77774624 100644 --- a/src/main/resources/api-definition/rbac-grant-schemas.yaml +++ b/src/main/resources/api-definition/rbac-grant-schemas.yaml @@ -6,16 +6,14 @@ components: RbacGrant: type: object properties: - userUuid: - type: string - format: uuid - roleUuid: - type: string - format: uuid assumed: type: boolean - empowered: - type: boolean + grantedRoleUuid: + type: string + format: uuid + granteeUserUuid: + type: string + format: uuid required: - - userUuid - - roleUuid + - grantedRoleUuid + - granteeUserUuid 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 a3ddbccc..6c84e8ef 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 @@ -353,11 +353,10 @@ $$; */ create table RbacGrants ( - ascendantUuid uuid references RbacReference (uuid) on delete cascade, - descendantUuid uuid references RbacReference (uuid) on delete cascade, - 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 + grantedByRoleUuid uuid references RbacRole (uuid) on delete cascade, + ascendantUuid uuid references RbacReference (uuid) on delete cascade, + descendantUuid uuid references RbacReference (uuid) on delete cascade, + assumed boolean not null default true, -- auto assumed (true) vs. needs assumeRoles (false) primary key (ascendantUuid, descendantUuid) ); create index on RbacGrants (ascendantUuid); @@ -463,8 +462,8 @@ begin perform assertReferenceType('permissionId (descendant)', permissionIds[i], 'RbacPermission'); insert - into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered) - values (roleUuid, permissionIds[i], true, true, false) + into RbacGrants (ascendantUuid, descendantUuid, assumed) + values (roleUuid, permissionIds[i], true) on conflict do nothing; -- allow granting multiple times end loop; end; @@ -476,13 +475,13 @@ begin perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole'); perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); - if (isGranted(subRoleId, superRoleId)) then + if isGranted(subRoleId, superRoleId) then raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId; end if; insert - into RbacGrants (ascendantUuid, descendantUuid, managed, assumed, empowered) - values (superRoleId, subRoleId, true, doAssume, false) + into RbacGrants (ascendantuuid, descendantUuid, assumed) + values (superRoleId, subRoleId, doAssume) on conflict do nothing; -- allow granting multiple times end; $$; @@ -497,48 +496,6 @@ begin end if; end; $$; -create or replace procedure grantRoleToUser(roleUuid uuid, userUuid uuid) - language plpgsql as $$ -begin - perform assertReferenceType('roleId (descendant)', roleUuid, 'RbacRole'); - perform assertReferenceType('userId (ascendant)', userUuid, 'RbacUser'); - - insert - 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; $$; ---// - -- ============================================================================ --changeset rbac-base-QUERY-ACCESSIBLE-OBJECT-UUIDS:1 endDelimiter:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/2022-07-28-006-rbac-current.sql b/src/main/resources/db/changelog/2022-07-28-006-rbac-current.sql index e2f24423..2ced4045 100644 --- a/src/main/resources/db/changelog/2022-07-28-006-rbac-current.sql +++ b/src/main/resources/db/changelog/2022-07-28-006-rbac-current.sql @@ -23,6 +23,7 @@ begin if (currentUser is null or currentUser = '') then raise exception '[401] hsadminng.currentUser must be defined, please use "SET LOCAL ...;"'; end if; + raise debug 'currentUser: %', currentUser; return currentUser; end; $$; diff --git a/src/main/resources/db/changelog/2022-07-28-007-rbac-user-grant.sql b/src/main/resources/db/changelog/2022-07-28-007-rbac-user-grant.sql new file mode 100644 index 00000000..e1b5c712 --- /dev/null +++ b/src/main/resources/db/changelog/2022-07-28-007-rbac-user-grant.sql @@ -0,0 +1,101 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset rbac-user-grant-GRANT-ROLE-TO-USER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace function assumedRoleUuid() + returns uuid + stable leakproof + language plpgsql as $$ +declare + currentSubjectUuids uuid[]; +begin + -- exactly one role must be assumed, not none not more than one + if cardinality(assumedRoles()) <> 1 then + raise exception '[400] Granting roles to user is only possible if exactly one role is assumed, given: %', assumedRoles(); + end if; + + currentSubjectUuids := currentSubjectIds(); + return currentSubjectUuids[1]; +end; $$; + +create or replace procedure grantRoleToUserUnchecked(grantedByRoleUuid uuid, roleUuid uuid, userUuid uuid, doAssume boolean = true) + language plpgsql as $$ +begin + perform assertReferenceType('grantingRoleUuid', grantedByRoleUuid, 'RbacRole'); + perform assertReferenceType('roleId (descendant)', roleUuid, 'RbacRole'); + perform assertReferenceType('userId (ascendant)', userUuid, 'RbacUser'); + + insert + into RbacGrants (grantedByRoleUuid, ascendantUuid, descendantUuid, assumed) + values (grantedByRoleUuid, userUuid, roleUuid, doAssume); + -- 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; $$; + +create or replace procedure grantRoleToUser(grantedByRoleUuid uuid, grantedRoleUuid uuid, userUuid uuid, doAssume boolean = true) + language plpgsql as $$ +begin + perform assertReferenceType('grantingRoleUuid', grantedByRoleUuid, 'RbacRole'); + perform assertReferenceType('grantedRoleUuid (descendant)', grantedRoleUuid, 'RbacRole'); + perform assertReferenceType('userUuid (ascendant)', userUuid, 'RbacUser'); + + if NOT isGranted(currentSubjectIds(), grantedByRoleUuid) then + raise exception '[403] Access to granted-by-role % forbidden for %', grantedByRoleUuid, currentSubjects(); + end if; + + if NOT isGranted(grantedByRoleUuid, grantedRoleUuid) then + raise exception '[403] Access to granted role % forbidden for %', grantedRoleUuid, currentSubjects(); + end if; + + insert + into RbacGrants (grantedByRoleUuid, ascendantUuid, descendantUuid, assumed) + values (grantedByRoleUuid, userUuid, grantedRoleUuid, doAssume); + -- 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; $$; +--// + + +-- ============================================================================ +--changeset rbac-user-grant-REVOKE-ROLE-FROM-USER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace procedure checkRevokeRoleFromUserPreconditions(grantedByRoleUuid uuid, grantedRoleUuid uuid, userUuid uuid) + language plpgsql as $$ +begin + perform assertReferenceType('grantedByRoleUuid', grantedByRoleUuid, 'RbacRole'); + perform assertReferenceType('grantedRoleUuid (descendant)', grantedRoleUuid, 'RbacRole'); + perform assertReferenceType('userUuid (ascendant)', userUuid, 'RbacUser'); + + if NOT isGranted(currentSubjectIds(), grantedByRoleUuid) then + raise exception '[403] Revoking role created by % is forbidden for %.', grantedByRoleUuid, currentSubjects(); + end if; + + if NOT isGranted(grantedByRoleUuid, grantedRoleUuid) then + raise exception '[403] Revoking role % is forbidden for %.', grantedRoleUuid, currentSubjects(); + end if; + + if NOT isGranted(currentSubjectIds(), grantedByRoleUuid) then + raise exception '[403] Revoking role granted by % is forbidden for %.', grantedByRoleUuid, currentSubjects(); + end if; + + if NOT isGranted(userUuid, grantedRoleUuid) then + raise exception '[404] No such grant found granted by % for user % to role %.', grantedByRoleUuid, userUuid, grantedRoleUuid; + end if; +end; $$; + +create or replace procedure revokeRoleFromUser(grantedByRoleUuid uuid, grantedRoleUuid uuid, userUuid uuid) + language plpgsql as $$ +begin + call checkRevokeRoleFromUserPreconditions(grantedByRoleUuid, grantedRoleUuid, userUuid); + + raise INFO 'delete from RbacGrants where ascendantUuid = % and descendantUuid = %', userUuid, grantedRoleUuid; + delete from RbacGrants as g + where g.ascendantUuid = userUuid and g.descendantUuid = grantedRoleUuid + and g.grantedByRoleUuid = revokeRoleFromUser.grantedByRoleUuid; +end; $$; +--/ diff --git a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql b/src/main/resources/db/changelog/2022-07-28-008-rbac-views.sql similarity index 81% rename from src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql rename to src/main/resources/db/changelog/2022-07-28-008-rbac-views.sql index 85e0f922..797aa631 100644 --- a/src/main/resources/db/changelog/2022-07-28-007-rbac-views.sql +++ b/src/main/resources/db/changelog/2022-07-28-008-rbac-views.sql @@ -33,23 +33,25 @@ grant all privileges on rbacrole_rv to restricted; */ 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, + -- @formatter:off + select o.objectTable || '#' || findIdNameByObjectUuid(o.objectTable, o.uuid) || '.' || r.roletype as grantedByRoleIdName, + g.objectTable || '#' || g.objectIdName || '.' || g.roletype as grantedRoleIdName, g.userName, g.assumed, + g.grantedByRoleUuid, g.descendantUuid as grantedRoleUuid, g.ascendantUuid as userUuid, + g.objectTable, g.objectUuid, g.objectIdName, g.roleType as grantedRoleType + from ( + select g.grantedbyroleuuid, g.ascendantuuid, g.descendantuuid, g.assumed, + 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 roleIdName; + ) as g + join RbacRole as r on r.uuid = grantedByRoleUuid + join RbacObject as o on o.uuid = r.objectUuid + order by grantedRoleIdName; + -- @formatter:on grant all privileges on rbacrole_rv to restricted; --// @@ -67,15 +69,10 @@ create or replace function insertRbacGrant() 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)); + call grantRoleToUser(assumedRoleUuid(), new.grantedRoleUuid, new.userUuid, new.assumed); select grv.* from RbacGrants_RV grv - where grv.userUuid=new.userUuid and grv.roleUuid=new.roleUuid + where grv.userUuid=new.userUuid and grv.grantedRoleUuid=new.grantedRoleUuid into newGrant; return newGrant; end; $$; @@ -88,6 +85,33 @@ create trigger insertRbacGrant_Trigger on RbacGrants_rv for each row execute function insertRbacGrant(); +--/ + + +-- ============================================================================ +--changeset rbac-views-GRANTS-RV-DELETE-TRIGGER:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Instead of delete trigger function for RbacGrants_RV. + */ +create or replace function deleteRbacGrant() + returns trigger + language plpgsql as $$ +begin + call revokeRoleFromUser(assumedRoleUuid(), old.grantedRoleUuid, old.userUuid); + return null; +end; $$; + +/* + Creates an instead of delete trigger for the RbacGrants_rv view. + */ +create trigger deleteRbacGrant_Trigger + instead of delete + on RbacGrants_rv + for each row +execute function deleteRbacGrant(); +--/ -- ============================================================================ @@ -220,3 +244,4 @@ begin ) xp; -- @formatter:on end; $$; +--// diff --git a/src/main/resources/db/changelog/2022-07-28-020-rbac-role-builder.sql b/src/main/resources/db/changelog/2022-07-28-020-rbac-role-builder.sql index 255b6ccb..79db89f0 100644 --- a/src/main/resources/db/changelog/2022-07-28-020-rbac-role-builder.sql +++ b/src/main/resources/db/changelog/2022-07-28-020-rbac-role-builder.sql @@ -26,7 +26,7 @@ create or replace function withoutPermissions() language plpgsql strict as $$ begin - return row (array[]::uuid[]); + return row (array []::uuid[]); end; $$; --// @@ -167,7 +167,8 @@ create or replace function createRole( permissions RbacPermissions, superRoles RbacSuperRoles, subRoles RbacSubRoles = null, - users RbacUsers = null + users RbacUsers = null, + grantingRoleUuid uuid = null ) returns uuid called on null input @@ -200,7 +201,7 @@ begin if users is not null then foreach userUuid in array users.useruUids loop - call grantRoleToUser(roleUuid, userUuid); + call grantRoleToUserUnchecked(grantingRoleUuid, roleUuid, userUuid); end loop; end if; @@ -210,26 +211,47 @@ end; $$; create or replace function createRole( roleDescriptor RbacRoleDescriptor, permissions RbacPermissions, - users RbacUsers = null + users RbacUsers = null, + grantingRoleUuid uuid = null ) returns uuid called on null input language plpgsql as $$ begin - return createRole(roleDescriptor, permissions, null, null, users); + return createRole(roleDescriptor, permissions, null, null, users, grantingRoleUuid); end; $$; create or replace function createRole( roleDescriptor RbacRoleDescriptor, permissions RbacPermissions, subRoles RbacSubRoles, - users RbacUsers = null + users RbacUsers = null, + grantingRoleUuid uuid = null ) returns uuid called on null input language plpgsql as $$ begin - return createRole(roleDescriptor, permissions, null, subRoles, users); + return createRole(roleDescriptor, permissions, null, subRoles, users, grantingRoleUuid); end; $$; - --// + +-- ================================================================= +-- CREATE ROLE +--changeset rbac-role-builder-GRANTED-BY-ROLE:1 endDelimiter:--// +-- ----------------------------------------------------------------- + +/* + Used in role-builder-DSL to convert a role descriptor to it's uuid + for use as `grantedByRoleUuid`. +*/ +create or replace function grantedByRole(roleDescriptor RbacRoleDescriptor) + returns uuid + strict leakproof + language plpgsql as $$ +begin + return getRoleId(roledescriptor, 'fail'); +end; $$; +--// + + diff --git a/src/main/resources/db/changelog/2022-07-29-050-hs-base.sql b/src/main/resources/db/changelog/2022-07-29-050-hs-base.sql index 82cd5fe4..3b8b723f 100644 --- a/src/main/resources/db/changelog/2022-07-29-050-hs-base.sql +++ b/src/main/resources/db/changelog/2022-07-29-050-hs-base.sql @@ -104,8 +104,8 @@ do language plpgsql $$ admins uuid ; begin admins = findRoleId(hostsharingAdmin()); - call grantRoleToUser(admins, createRbacUser('mike@hostsharing.net')); - call grantRoleToUser(admins, createRbacUser('sven@hostsharing.net')); + call grantRoleToUserUnchecked(admins, admins, createRbacUser('mike@hostsharing.net')); + call grantRoleToUserUnchecked(admins, admins, createRbacUser('sven@hostsharing.net')); end; $$; --// 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 73654484..391cfe5e 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 @@ -77,7 +77,8 @@ begin customerAdmin(NEW), grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view', 'add-package']), -- NO auto assume for customer owner to avoid exploding permissions for administrators - withUser(NEW.adminUserName, 'create') -- implicitly ignored if null + withUser(NEW.adminUserName, 'create'), -- implicitly ignored if null + grantedByRole(hostsharingAdmin()) ); -- allow the customer owner role (thus administrators) to assume the customer admin role diff --git a/src/main/resources/db/changelog/2022-07-29-062-hs-customer-test-data.sql b/src/main/resources/db/changelog/2022-07-29-062-hs-customer-test-data.sql index 32789e91..6f69c831 100644 --- a/src/main/resources/db/changelog/2022-07-29-062-hs-customer-test-data.sql +++ b/src/main/resources/db/changelog/2022-07-29-062-hs-customer-test-data.sql @@ -37,8 +37,8 @@ begin loop currentTask = 'creating RBAC test customer #' || t; set local hsadminng.currentUser to 'mike@hostsharing.net'; - set local hsadminng.assumedRoles = ''; - set local hsadminng.currentTask to currentTask; + set local hsadminng.assumedRoles to 'global#hostsharing.admin'; + execute format('set local hsadminng.currentTask to %L', currentTask); -- When a new customer is created, custReference = testCustomerReference(t); diff --git a/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql b/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql index f2374930..f38435ec 100644 --- a/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql +++ b/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql @@ -7,51 +7,55 @@ Creates test data for the package main table. */ create or replace procedure createPackageTestData( - minCustomerReference integer, -- skip customers with reference below this - doCommitAfterEach boolean -- only for mass data creation outside of Liquibase + minCustomerReference integer, -- skip customers with reference below this + doCommitAfterEach boolean -- only for mass data creation outside of Liquibase ) language plpgsql as $$ - declare - cust customer; - pacName varchar; - currentTask varchar; - custAdmin varchar; - pac package; - begin - set hsadminng.currentUser to ''; +declare + cust customer; + custAdminUser varchar; + custAdminRole varchar; + pacName varchar; + currentTask varchar; + pac package; +begin + set hsadminng.currentUser to ''; - for cust in (select * from customer) - loop - CONTINUE WHEN cust.reference < minCustomerReference; + for cust in (select * from customer) + loop + continue when cust.reference < minCustomerReference; - for t in 0..2 - loop - pacName = cust.prefix || to_char(t, 'fm00'); - currentTask = 'creating RBAC test package #' || pacName || ' for customer ' || cust.prefix || ' #' || - cust.uuid; - raise notice 'task: %', currentTask; + for t in 0..2 + loop + pacName = cust.prefix || to_char(t, 'fm00'); + currentTask = 'creating RBAC test package #' || pacName || ' for customer ' || cust.prefix || ' #' || + cust.uuid; - custAdmin = 'admin@' || cust.prefix || '.example.com'; - set local hsadminng.currentUser to custAdmin; - set local hsadminng.assumedRoles = ''; - set local hsadminng.currentTask to currentTask; + custAdminUser = 'admin@' || cust.prefix || '.example.com'; + custAdminRole = 'customer#' || cust.prefix || '.admin'; + execute format('set local hsadminng.currentUser to %L', custAdminUser); + execute format('set local hsadminng.assumedRoles to %L', custAdminRole); + execute format('set local hsadminng.currentTask to %L', currentTask); + raise notice 'task: % by % as %', currentTask, custAdminUser, custAdminRole; - insert - into package (customerUuid, name, description) - values (cust.uuid, pacName, 'Here can add your own description of package ' || pacName || '.') - returning * into pac; + insert + into package (customerUuid, name, description) + values (cust.uuid, pacName, 'Here can add your own description of package ' || pacName || '.') + returning * into pac; - call grantRoleToUser( - findRoleId(packageAdmin(pac)), - createRbacUser(pacName || '@' || cust.prefix || '.example.com')); + call grantRoleToUser( + getRoleId(customerAdmin(cust), 'fail'), + findRoleId(packageAdmin(pac)), + createRbacUser(pacName || '@' || cust.prefix || '.example.com'), + true); - end loop; - end loop; + end loop; + end loop; - if doCommitAfterEach then - commit; - end if; - end; + if doCommitAfterEach then + commit; + end if; +end ; $$; --// diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 5c3a63ac..04dca17e 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -12,7 +12,9 @@ databaseChangeLog: - include: file: db/changelog/2022-07-28-006-rbac-current.sql - include: - file: db/changelog/2022-07-28-007-rbac-views.sql + file: db/changelog/2022-07-28-007-rbac-user-grant.sql + - include: + file: db/changelog/2022-07-28-008-rbac-views.sql - include: file: db/changelog/2022-07-28-020-rbac-role-builder.sql - include: diff --git a/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java b/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java index 25b90e4e..2e91ecf9 100644 --- a/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java +++ b/src/test/java/net/hostsharing/hsadminng/context/ContextIntegrationTests.java @@ -4,6 +4,7 @@ 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.test.annotation.DirtiesContext; import javax.transaction.Transactional; @@ -11,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @ComponentScan(basePackageClasses = Context.class) +@DirtiesContext class ContextIntegrationTests { @Autowired diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java index d48f03e8..ed532fec 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hscustomer/CustomerRepositoryIntegrationTest.java @@ -7,6 +7,7 @@ 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 javax.persistence.PersistenceException; @@ -19,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @ComponentScan(basePackageClasses = { Context.class, CustomerRepository.class }) +@DirtiesContext class CustomerRepositoryIntegrationTest { @Autowired 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 301ca632..b459dfba 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepositoryIntegrationTest.java @@ -8,6 +8,7 @@ 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 javax.transaction.Transactional; @@ -18,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @ComponentScan(basePackageClasses = { Context.class, CustomerRepository.class }) +@DirtiesContext class PackageRepositoryIntegrationTest { @Autowired diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java index ed963bf3..d65db40c 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantControllerAcceptanceTest.java @@ -15,19 +15,22 @@ 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 org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; 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; +import static org.hamcrest.CoreMatchers.containsString; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Accepts({ "ROL:S(Schema)" }) +@Transactional(propagation = Propagation.NEVER) class RbacGrantControllerAcceptanceTest { @LocalServerPort @@ -57,23 +60,25 @@ class RbacGrantControllerAcceptanceTest { // given final var givenNewUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com"; - final String givenPackageAdmin = "aaa00@aaa.example.com"; + final String givenCurrentUserPackageAdmin = "aaa00@aaa.example.com"; + final String givenAssumedRole = "package#aaa00.admin"; final var givenOwnPackageAdminRole = "package#aaa00.admin"; + // when RestAssured // @formatter:off .given() - .header("current-user", givenPackageAdmin) + .header("current-user", givenCurrentUserPackageAdmin) + .header("assumed-roles", givenAssumedRole) .contentType(ContentType.JSON) .body(""" { - "userUuid": "%s", - "roleUuid": "%s", "assumed": true, - "empowered": false + "grantedRoleUuid": "%s", + "granteeUserUuid": "%s" } """.formatted( - createRBacUser(givenNewUserName).getUuid().toString(), - findRbacRoleByName(givenOwnPackageAdminRole).getUuid().toString()) + findRbacRoleByName(givenOwnPackageAdminRole).getUuid().toString(), + createRBacUser(givenNewUserName).getUuid().toString()) ) .port(port) .when() @@ -83,9 +88,11 @@ class RbacGrantControllerAcceptanceTest { // @formatter:on // then - assertThat(findAllGrantsOfUser(givenPackageAdmin)) + assertThat(findAllGrantsOfUser(givenCurrentUserPackageAdmin)) .extracting(RbacGrantEntity::toDisplay) - .contains("grant( " + givenNewUserName + " -> " + givenOwnPackageAdminRole + ": assumed )"); + .contains("{ grant assumed role " + givenOwnPackageAdminRole + + " to user " + givenNewUserName + + " by role " + givenAssumedRole + " }"); } @Test @@ -94,35 +101,38 @@ class RbacGrantControllerAcceptanceTest { // given final var givenNewUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com"; - final String givenPackageAdmin = "aaa00@aaa.example.com"; + final String givenCurrentUserPackageAdmin = "aaa00@aaa.example.com"; + final String givenAssumedRole = "package#aaa00.admin"; 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) + .header("current-user", givenCurrentUserPackageAdmin) + .header("assumed-roles", givenAssumedRole) + .contentType(ContentType.JSON) + .body(""" + { + "assumed": true, + "grantedRoleUuid": "%s", + "granteeUserUuid": "%s" + } + """.formatted( + findRbacRoleByName(givenAlienPackageAdminRole).getUuid().toString(), + createRBacUser(givenNewUserName).getUuid().toString()) + ) + .port(port) .when() - .post("http://localhost/api/rbac-grants") + .post("http://localhost/api/rbac-grants") .then().assertThat() - .statusCode(403); + .body("message", containsString("Access to granted role")) + .body("message", containsString("forbidden for {package#aaa00.admin}")) + .statusCode(403); // @formatter:on // then - assertThat(findAllGrantsOfUser(givenPackageAdmin)) - .extracting(RbacGrantEntity::getUserName) + assertThat(findAllGrantsOfUser(givenCurrentUserPackageAdmin)) + .extracting(RbacGrantEntity::getGranteeUserName) .doesNotContain(givenNewUserName); } @@ -134,9 +144,9 @@ class RbacGrantControllerAcceptanceTest { } RbacUserEntity createRBacUser(final String userName) { - return jpaAttempt.transacted(() -> { - return rbacUserRepository.create(new RbacUserEntity(UUID.randomUUID(), userName)); - }).returnedValue(); + return jpaAttempt.transacted(() -> + rbacUserRepository.create(new RbacUserEntity(UUID.randomUUID(), userName)) + ).returnedValue(); } RbacRoleEntity findRbacRoleByName(final String roleName) { @@ -145,5 +155,4 @@ class RbacGrantControllerAcceptanceTest { 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 index 82832518..4d83a990 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantRepositoryIntegrationTest.java @@ -3,7 +3,9 @@ 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.RbacUserEntity; import net.hostsharing.hsadminng.rbac.rbacuser.RbacUserRepository; +import net.hostsharing.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,15 +13,18 @@ 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 org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityManager; import java.util.List; +import java.util.UUID; import static net.hostsharing.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@ComponentScan(basePackageClasses = { Context.class, RbacGrantRepository.class }) +@ComponentScan(basePackageClasses = { RbacGrantRepository.class, Context.class, JpaAttempt.class }) @DirtiesContext @Accepts({ "GRT:S(Schema)" }) class RbacGrantRepositoryIntegrationTest { @@ -39,6 +44,9 @@ class RbacGrantRepositoryIntegrationTest { @Autowired EntityManager em; + @Autowired + JpaAttempt jpaAttempt; + @Nested class FindAllRbacGrants { @@ -54,7 +62,7 @@ class RbacGrantRepositoryIntegrationTest { // then exactlyTheseRbacGrantsAreReturned( result, - "grant( aaa00@aaa.example.com -> package#aaa00.admin: managed assumed empowered )"); + "{ grant assumed role package#aaa00.admin to user aaa00@aaa.example.com by role customer#aaa.admin }"); } @Test @@ -69,28 +77,26 @@ class RbacGrantRepositoryIntegrationTest { // 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 )"); + "{ grant assumed role customer#aaa.admin to user admin@aaa.example.com by role global#hostsharing.admin }", + "{ grant assumed role package#aaa00.admin to user aaa00@aaa.example.com by role customer#aaa.admin }", + "{ grant assumed role package#aaa01.admin to user aaa01@aaa.example.com by role customer#aaa.admin }", + "{ grant assumed role package#aaa02.admin to user aaa02@aaa.example.com by role customer#aaa.admin }"); } @Test @Accepts({ "GRT:L(List)" }) - public void customerAdmin_withAssumedRole_cannotViewRbacGrants() { + public void customerAdmin_withAssumedRole_canOnlyViewRbacGrantsVisibleByAssumedRole() { // given: currentUser("admin@aaa.example.com"); - assumedRoles("package#aab00.admin"); + assumedRoles("package#aaa00.admin"); // when - final var result = attempt( - em, - () -> rbacGrantRepository.findAll()); + final var result = rbacGrantRepository.findAll(); // then - result.assertExceptionWithRootCauseMessage( - JpaSystemException.class, - "[403] user admin@aaa.example.com", "has no permission to assume role package#aab00#admin"); + exactlyTheseRbacGrantsAreReturned( + result, + "{ grant assumed role package#aaa00.admin to user aaa00@aaa.example.com by role customer#aaa.admin }"); } } @@ -102,24 +108,72 @@ class RbacGrantRepositoryIntegrationTest { 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(); + assumedRoles("customer#aaa.admin"); + final var givenArbitraryUserUuid = rbacUserRepository.findUuidByName("aac00@aac.example.com"); + final var givenOwnPackageRoleUuid = rbacRoleRepository.findByRoleName("package#aaa00.admin").getUuid(); // when final var grant = RbacGrantEntity.builder() - .userUuid(userUuid).roleUuid(roleUuid) - .assumed(true).empowered(false) + .granteeUserUuid(givenArbitraryUserUuid).grantedRoleUuid(givenOwnPackageRoleUuid) + .assumed(true) .build(); final var attempt = attempt(em, () -> rbacGrantRepository.save(grant) ); // then - assertThat(attempt.wasSuccessful()).isTrue(); + assertThat(attempt.caughtException()).isNull(); assertThat(rbacGrantRepository.findAll()) .extracting(RbacGrantEntity::toDisplay) - .contains("grant( aac00@aac.example.com -> package#aaa00.admin: assumed )"); + .contains("{ grant assumed role package#aaa00.admin to user aac00@aac.example.com by role customer#aaa.admin }"); } + + @Test + @Accepts({ "GRT:C(Create)" }) + @Transactional(propagation = Propagation.NEVER) + public void packageAdmin_canNotGrantPackageOwnerRole() { + // given + record Given(RbacUserEntity arbitraryUser, UUID packageOwnerRoleUuid) {} + final var given = jpaAttempt.transacted(() -> { + // to find the uuids of we need to have access rights to these + currentUser("admin@aaa.example.com"); + return new Given( + createNewUser(), // eigene Transaktion? + rbacRoleRepository.findByRoleName("package#aaa00.owner").getUuid() + ); + }).returnedValue(); + + // when + final var attempt = jpaAttempt.transacted(() -> { + // now we try to use these uuids as a less privileged user + currentUser("aaa00@aaa.example.com"); + assumedRoles("package#aaa00.admin"); + final var grant = RbacGrantEntity.builder() + .granteeUserUuid(given.arbitraryUser.getUuid()) + .grantedRoleUuid(given.packageOwnerRoleUuid) + .assumed(true) + .build(); + rbacGrantRepository.save(grant); + }); + + // then + attempt.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "ERROR: [403] Access to granted role " + given.packageOwnerRoleUuid + + " forbidden for {package#aaa00.admin}"); + jpaAttempt.transacted(() -> { + currentUser(given.arbitraryUser.getName()); + assertThat(rbacGrantRepository.findAll()) + .extracting(RbacGrantEntity::toDisplay) + .hasSize(0); + // "{ grant assumed role package#aaa00.admin to user aac00@aac.example.com by role customer#aaa.admin }"); + }); + } + } + + private RbacUserEntity createNewUser() { + return rbacUserRepository.create( + new RbacUserEntity(null, "test-user-" + System.currentTimeMillis() + "@example.com")); } void currentUser(final String currentUser) { @@ -134,7 +188,7 @@ class RbacGrantRepositoryIntegrationTest { void exactlyTheseRbacGrantsAreReturned(final List actualResult, final String... expectedGrant) { assertThat(actualResult) - .filteredOn(g -> !g.getUserName().startsWith("test-user-")) // ignore test-users created by other tests + .filteredOn(g -> !g.getGranteeUserName().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/rbacuser/RbacUserRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java index 7519d5ad..67b61c66 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java @@ -10,6 +10,7 @@ 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.Commit; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest @ComponentScan(basePackageClasses = { RbacUserRepository.class, Context.class, JpaAttempt.class }) +@DirtiesContext class RbacUserRepositoryIntegrationTest { @Autowired @@ -58,7 +60,7 @@ class RbacUserRepositoryIntegrationTest { @Test @Commit - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional(propagation = Propagation.NEVER) void anyoneCanCreateTheirOwnUser_committed() { // given: diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/test/JpaAttempt.java index a0720367..4d4499d7 100644 --- a/src/test/java/net/hostsharing/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/test/JpaAttempt.java @@ -4,8 +4,7 @@ import junit.framework.AssertionFailedError; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import javax.persistence.EntityManager; import java.util.Optional; @@ -32,20 +31,16 @@ import static org.assertj.core.api.Assertions.assertThat; public class JpaAttempt { @Autowired - private final EntityManager em; - - public JpaAttempt(final EntityManager em) { - this.em = em; - } + private TransactionTemplate transactionTemplate; public static JpaResult attempt(final EntityManager em, final Supplier code) { try { - final var result = new JpaResult(code.get(), null); + final var result = JpaResult.forValue(code.get()); em.flush(); em.clear(); return result; - } catch (RuntimeException exc) { - return new JpaResult(null, exc); + } catch (final RuntimeException exc) { + return JpaResult.forException(exc); } } @@ -56,29 +51,50 @@ public class JpaAttempt { }); } - @Transactional(propagation = Propagation.REQUIRES_NEW) public JpaResult transacted(final Supplier code) { - return attempt(em, code); + try { + return JpaResult.forValue( + transactionTemplate.execute(transactionStatus -> code.get())); + } catch (final RuntimeException exc) { + return JpaResult.forException(exc); + } } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void transacted(final Runnable code) { - attempt(em, () -> { - code.run(); - return null; - }); + public JpaResult transacted(final Runnable code) { + try { + transactionTemplate.execute(transactionStatus -> { + code.run(); + return null; + }); + return JpaResult.forVoidValue(); + } catch (final RuntimeException exc) { + + return new JpaResult<>(null, exc); + } } public static class JpaResult { - final T result; - final RuntimeException exception; + private final T result; + private final RuntimeException exception; - public JpaResult(final T result, final RuntimeException exception) { + private JpaResult(final T result, final RuntimeException exception) { this.result = result; this.exception = exception; } + static JpaResult forVoidValue() { + return new JpaResult<>(null, null); + } + + public static JpaResult forValue(final T value) { + return new JpaResult<>(value, null); + } + + public static JpaResult forException(final RuntimeException exception) { + return new JpaResult<>(null, exception); + } + public boolean wasSuccessful() { return exception == null; }