diff --git a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateOrUpdateStage.java b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateOrUpdateStage.java index 1d724ef62..150864aa2 100644 --- a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateOrUpdateStage.java +++ b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateOrUpdateStage.java @@ -26,11 +26,11 @@ protected void collectCreateOps( List> createOps = new ArrayList<>(noKeyCreate.size() + withKeyCreate.size()); for (EntityUpdate u : noKeyCreate) { - createOps.add(new ChangeOperation<>(ChangeOperationType.CREATE, null, u)); + createOps.add(new ChangeOperation<>(ChangeOperationType.CREATE, u.getEntity(), null, u)); } for (EntityUpdate u : withKeyCreate) { - createOps.add(new ChangeOperation<>(ChangeOperationType.CREATE, null, u)); + createOps.add(new ChangeOperation<>(ChangeOperationType.CREATE, u.getEntity(), null, u)); } context.setChangeOperations(ChangeOperationType.CREATE, createOps); diff --git a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateStage.java b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateStage.java index 9d4e73958..02df6f232 100644 --- a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateStage.java +++ b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapCreateStage.java @@ -17,13 +17,13 @@ public class CayenneMapCreateStage extends CayenneMapChangesStage { @Override protected void map(UpdateContext context) { - List> ops = new ArrayList<>(); + List> ops = new ArrayList<>(context.getUpdates().size()); for (EntityUpdate u : context.getUpdates()) { // TODO: when EntityUpdate contains id, there may be multiple updates for the same key // that need to be merged in a single operation to avoid commit errors... I suppose for // now the users must use "createOrUpdate" if that's anticipated instead of "create" - ops.add(new ChangeOperation<>(ChangeOperationType.CREATE, null, u)); + ops.add(new ChangeOperation<>(ChangeOperationType.CREATE, u.getEntity(), null, u)); } context.setChangeOperations(ChangeOperationType.CREATE, ops); diff --git a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapIdempotentFullSyncStage.java b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapIdempotentFullSyncStage.java index b2ed56baf..1ecd75b68 100644 --- a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapIdempotentFullSyncStage.java +++ b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapIdempotentFullSyncStage.java @@ -25,7 +25,8 @@ public class CayenneMapIdempotentFullSyncStage extends CayenneMapIdempotentCreat @Override protected void collectUpdateDeleteOps( - UpdateContext context, ObjectMapper mapper, + UpdateContext context, + ObjectMapper mapper, UpdateMap updateMap) { List existing = existingObjects(context, updateMap.getIds(), mapper); @@ -39,12 +40,12 @@ protected void collectUpdateDeleteOps( for (T o : existing) { Object key = mapper.keyForObject(o); - EntityUpdate updates = updateMap.remove(key); + EntityUpdate update = updateMap.remove(key); - if (updates == null) { - deleteOps.add(new ChangeOperation<>(ChangeOperationType.DELETE, o, null)); + if (update == null) { + deleteOps.add(new ChangeOperation<>(ChangeOperationType.DELETE, context.getEntity().getAgEntity(), o, null)); } else { - updateOps.add(new ChangeOperation<>(ChangeOperationType.UPDATE, o, updates)); + updateOps.add(new ChangeOperation<>(ChangeOperationType.UPDATE, update.getEntity(), o, update)); } } diff --git a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapUpdateStage.java b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapUpdateStage.java index b82fae3f3..f103f020a 100644 --- a/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapUpdateStage.java +++ b/agrest-cayenne/src/main/java/io/agrest/cayenne/processor/update/CayenneMapUpdateStage.java @@ -68,7 +68,7 @@ protected void collectUpdateDeleteOps( throw AgException.internalServerError("Invalid key item: %s", key); } - updateOps.add(new ChangeOperation<>(ChangeOperationType.UPDATE, o, update)); + updateOps.add(new ChangeOperation<>(ChangeOperationType.UPDATE, update.getEntity(), o, update)); } context.setChangeOperations(ChangeOperationType.UPDATE, updateOps); diff --git a/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_CreateAuthorizerIT.java b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_CreateAuthorizerIT.java new file mode 100644 index 000000000..9596606fe --- /dev/null +++ b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_CreateAuthorizerIT.java @@ -0,0 +1,107 @@ +package io.agrest.cayenne; + +import io.agrest.Ag; +import io.agrest.EntityUpdate; +import io.agrest.SimpleResponse; +import io.agrest.cayenne.cayenne.main.E2; +import io.agrest.cayenne.cayenne.main.E3; +import io.agrest.cayenne.cayenne.main.E4; +import io.agrest.cayenne.unit.AgCayenneTester; +import io.agrest.cayenne.unit.DbTest; +import io.agrest.meta.AgEntity; +import io.bootique.junit5.BQTestTool; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.UriInfo; +import java.util.List; + +public class PUT_CreateAuthorizerIT extends DbTest { + + @BQTestTool + static final AgCayenneTester tester = tester(Resource.class) + .entities(E2.class, E3.class, E4.class) + .agCustomizer(ab -> ab + .entityOverlay(AgEntity.overlay(E2.class).createAuthorizer(u -> !"blocked".equals(u.getValues().get("name")))) + ).build(); + + @Test + public void testInStack_Allowed() { + + tester.target("/e2_stack_authorizer") + .put("[{\"name\":\"Bb\"},{\"name\":\"Aa\"}]") + .wasCreated(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "Aa").assertOneMatch(); + tester.e2().matcher().eq("name", "Bb").assertOneMatch(); + } + + @Test + public void testInStack_Blocked() { + + tester.target("/e2_stack_authorizer") + .put("[{\"name\":\"Bb\"},{\"name\":\"blocked\"}]") + .wasForbidden(); + + tester.e2().matcher().assertNoMatches(); + } + + @Test + public void testInRequestAndStack_Allowed() { + + tester.target("/e2_request_and_stack_authorizer/not_this") + .put("[{\"name\":\"Bb\"},{\"name\":\"Aa\"}]") + .wasCreated(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "Aa").assertOneMatch(); + tester.e2().matcher().eq("name", "Bb").assertOneMatch(); + } + + @Test + public void testInRequestAndStack_Blocked() { + + tester.target("/e2_request_and_stack_authorizer/not_this") + .put("[{\"name\":\"Bb\"},{\"name\":\"blocked\"}]") + .wasForbidden(); + + tester.e2().matcher().assertNoMatches(); + + tester.target("/e2_request_and_stack_authorizer/not_this") + .put("[{\"name\":\"not_this\"},{\"name\":\"Aa\"}]") + .wasForbidden(); + + tester.e2().matcher().assertNoMatches(); + } + + @Path("") + public static class Resource { + + @Context + private Configuration config; + + @PUT + @Path("e2_stack_authorizer") + public SimpleResponse putE2StackFilter(@Context UriInfo uriInfo, List> updates) { + return Ag.service(config).createOrUpdate(E2.class).uri(uriInfo).sync(updates); + } + + @PUT + @Path("e2_request_and_stack_authorizer/{name}") + public SimpleResponse putE2RequestAndStackFilter( + @Context UriInfo uriInfo, + @PathParam("name") String name, + List> updates) { + + return Ag.service(config).createOrUpdate(E2.class) + .uri(uriInfo) + .createAuthorizer(E2.class, u -> !name.equals(u.getValues().get("name"))) + .sync(updates); + } + } +} diff --git a/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_DeleteAuthorizerIT.java b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_DeleteAuthorizerIT.java new file mode 100644 index 000000000..cbc8e3f83 --- /dev/null +++ b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_DeleteAuthorizerIT.java @@ -0,0 +1,119 @@ +package io.agrest.cayenne; + +import io.agrest.Ag; +import io.agrest.EntityUpdate; +import io.agrest.SimpleResponse; +import io.agrest.cayenne.cayenne.main.E2; +import io.agrest.cayenne.cayenne.main.E3; +import io.agrest.cayenne.cayenne.main.E4; +import io.agrest.cayenne.unit.AgCayenneTester; +import io.agrest.cayenne.unit.DbTest; +import io.agrest.meta.AgEntity; +import io.bootique.junit5.BQTestTool; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Context; +import java.util.List; + +public class PUT_DeleteAuthorizerIT extends DbTest { + + @BQTestTool + static final AgCayenneTester tester = tester(Resource.class) + .entities(E2.class, E3.class, E4.class) + .agCustomizer(ab -> ab + .entityOverlay(AgEntity.overlay(E2.class).deleteAuthorizer(o -> !"dont_delete".equals(o.getName()))) + ).build(); + + @Test + public void testInStack_Allowed() { + + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_stack_authorizer") + .put("[{\"id\":2,\"name\":\"Bb\"}]") + .wasOk(); + + tester.e2().matcher().assertMatches(1); + tester.e2().matcher().eq("name", "Bb").assertOneMatch(); + } + + @Test + public void testInStack_Blocked() { + tester.e2().insertColumns("id_", "name") + .values(1, "dont_delete") + .values(2, "b") + .exec(); + + tester.target("/e2_stack_authorizer") + .put("[{\"id\":2,\"name\":\"Bb\"}]") + .wasForbidden(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "dont_delete").assertOneMatch(); + tester.e2().matcher().eq("name", "b").assertOneMatch(); + } + + @Test + public void testInRequestAndStack_Allowed() { + + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_request_and_stack_authorizer/not_this") + .put("[{\"id\":2,\"name\":\"Bb\"}]") + .wasOk(); + + tester.e2().matcher().assertMatches(1); + tester.e2().matcher().eq("name", "Bb").assertOneMatch(); + } + + @Test + public void testInRequestAndStack_Blocked() { + tester.e2().insertColumns("id_", "name") + .values(1, "dont_delete_this_either") + .values(2, "b") + .exec(); + + tester.target("/e2_request_and_stack_authorizer/dont_delete_this_either") + .put("[{\"id\":2,\"name\":\"Bb\"}]") + .wasForbidden(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "dont_delete_this_either").assertOneMatch(); + tester.e2().matcher().eq("name", "b").assertOneMatch(); + } + + @Path("") + public static class Resource { + + @Context + private Configuration config; + + @PUT + @Path("e2_stack_authorizer") + public SimpleResponse putE2StackFilter(List> updates) { + return Ag.service(config).idempotentFullSync(E2.class).sync(updates); + } + + @PUT + @Path("e2_request_and_stack_authorizer/{name}") + public SimpleResponse putE2RequestAndStackFilter( + @PathParam("name") String name, + List> updates) { + + return Ag.service(config) + .idempotentFullSync(E2.class) + .deleteAuthorizer(E2.class, o -> !name.equals(o.getName())) + .sync(updates); + } + } +} diff --git a/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_ReadFilterIT.java b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_ReadFilterIT.java index 0058a48d5..f56509ec6 100644 --- a/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_ReadFilterIT.java +++ b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_ReadFilterIT.java @@ -44,7 +44,7 @@ static ReadFilter oddFilter() { } @Test - public void testFilter_InStack() { + public void testInStack() { tester.e2().insertColumns("id_", "name") .values(1, "a") @@ -66,7 +66,7 @@ public void testFilter_InStack() { } @Test - public void testFilter_InStack_Nested() { + public void testInStack_Nested() { tester.e2().insertColumns("id_", "name") .values(1, "a") @@ -88,8 +88,7 @@ public void testFilter_InStack_Nested() { } @Test - public void testFilter_InStackAndRequest() { - + public void testInStackAndRequest() { tester.e2().insertColumns("id_", "name") .values(1, "a") diff --git a/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_UpdateAuthorizerIT.java b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_UpdateAuthorizerIT.java new file mode 100644 index 000000000..5d10c03d5 --- /dev/null +++ b/agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_UpdateAuthorizerIT.java @@ -0,0 +1,123 @@ +package io.agrest.cayenne; + +import io.agrest.Ag; +import io.agrest.EntityUpdate; +import io.agrest.SimpleResponse; +import io.agrest.cayenne.cayenne.main.E2; +import io.agrest.cayenne.cayenne.main.E3; +import io.agrest.cayenne.cayenne.main.E4; +import io.agrest.cayenne.unit.AgCayenneTester; +import io.agrest.cayenne.unit.DbTest; +import io.agrest.meta.AgEntity; +import io.bootique.junit5.BQTestTool; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Context; +import java.util.List; + +public class PUT_UpdateAuthorizerIT extends DbTest { + + @BQTestTool + static final AgCayenneTester tester = tester(Resource.class) + .entities(E2.class, E3.class, E4.class) + .agCustomizer(ab -> ab + .entityOverlay(AgEntity.overlay(E2.class).updateAuthorizer((o, u) -> o.getName().equals(u.getValues().get("name")))) + ).build(); + + @Test + public void testInStack_Allowed() { + + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_stack_authorizer") + .put("[{\"id\":2,\"name\":\"b\",\"address\":\"Bb\"},{\"id\":1,\"name\":\"a\",\"address\":\"Aa\"}]") + .wasOk(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "a").eq("address", "Aa").assertOneMatch(); + tester.e2().matcher().eq("name", "b").eq("address", "Bb").assertOneMatch(); + } + + @Test + public void testInStack_Blocked() { + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_stack_authorizer") + .put("[{\"id\":2,\"name\":\"b\",\"address\":\"Bb\"},{\"id\":1,\"name\":\"Aa\",\"address\":\"Aa\"}]") + .wasForbidden(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "a").eq("address", null).assertOneMatch(); + tester.e2().matcher().eq("name", "b").eq("address", null).assertOneMatch(); + } + + @Test + public void testInRequestAndStack_Allowed() { + + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_request_and_stack_authorizer/not_this") + .put("[{\"id\":2,\"name\":\"b\",\"address\":\"Bb\"},{\"id\":1,\"name\":\"a\",\"address\":\"Aa\"}]") + .wasOk(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "a").eq("address", "Aa").assertOneMatch(); + tester.e2().matcher().eq("name", "b").eq("address", "Bb").assertOneMatch(); + } + + @Test + public void testInRequestAndStack_Blocked() { + + tester.e2().insertColumns("id_", "name") + .values(1, "a") + .values(2, "b") + .exec(); + + tester.target("/e2_request_and_stack_authorizer/b") + .put("[{\"id\":2,\"name\":\"b\",\"address\":\"Bb\"},{\"id\":1,\"name\":\"a\",\"address\":\"Aa\"}]") + .wasForbidden(); + + tester.e2().matcher().assertMatches(2); + tester.e2().matcher().eq("name", "a").eq("address", null).assertOneMatch(); + tester.e2().matcher().eq("name", "b").eq("address", null).assertOneMatch(); + } + + + @Path("") + public static class Resource { + + @Context + private Configuration config; + + @PUT + @Path("e2_stack_authorizer") + public SimpleResponse putE2StackFilter(List> updates) { + return Ag.service(config).createOrUpdate(E2.class).sync(updates); + } + + @PUT + @Path("e2_request_and_stack_authorizer/{name}") + public SimpleResponse putE2RequestAndStackFilter( + @PathParam("name") String name, + List> updates) { + + return Ag.service(config) + .createOrUpdate(E2.class) + .updateAuthorizer(E2.class, (o, u) -> !name.equals(u.getValues().get("name"))) + .sync(updates); + } + } +} diff --git a/agrest-engine/src/main/java/io/agrest/UpdateBuilder.java b/agrest-engine/src/main/java/io/agrest/UpdateBuilder.java index 37a78495e..2509885ff 100644 --- a/agrest-engine/src/main/java/io/agrest/UpdateBuilder.java +++ b/agrest-engine/src/main/java/io/agrest/UpdateBuilder.java @@ -1,7 +1,10 @@ package io.agrest; +import io.agrest.access.CreateAuthorizer; +import io.agrest.access.DeleteAuthorizer; import io.agrest.access.PropertyFilter; import io.agrest.access.ReadFilter; +import io.agrest.access.UpdateAuthorizer; import io.agrest.constraints.Constraint; import io.agrest.meta.AgEntity; import io.agrest.meta.AgEntityOverlay; @@ -119,6 +122,30 @@ default UpdateBuilder readableFilter(Class entityType, ReadFilter f return entityOverlay(AgEntity.overlay(entityType).readFilter(filter)); } + /** + * @return this builder instance + * @since 4.8 + */ + default UpdateBuilder createAuthorizer(Class entityType, CreateAuthorizer authorizer) { + return entityOverlay(AgEntity.overlay(entityType).createAuthorizer(authorizer)); + } + + /** + * @return this builder instance + * @since 4.8 + */ + default UpdateBuilder updateAuthorizer(Class entityType, UpdateAuthorizer authorizer) { + return entityOverlay(AgEntity.overlay(entityType).updateAuthorizer(authorizer)); + } + + /** + * @return this builder instance + * @since 4.8 + */ + default UpdateBuilder deleteAuthorizer(Class entityType, DeleteAuthorizer authorizer) { + return entityOverlay(AgEntity.overlay(entityType).deleteAuthorizer(authorizer)); + } + /** * Installs request-scoped {@link AgEntityOverlay} that allows to customize, add or redefine request entity structure, * e.g. change property read/write access. This method can be called multiple times to add more than one overlay. diff --git a/agrest-engine/src/main/java/io/agrest/runtime/processor/update/AuthorizeChangesStage.java b/agrest-engine/src/main/java/io/agrest/runtime/processor/update/AuthorizeChangesStage.java index 0cc88151c..880469285 100644 --- a/agrest-engine/src/main/java/io/agrest/runtime/processor/update/AuthorizeChangesStage.java +++ b/agrest-engine/src/main/java/io/agrest/runtime/processor/update/AuthorizeChangesStage.java @@ -58,12 +58,33 @@ static void checkRules( for (ChangeOperation op : ops) { if (!filter.test(op)) { - Map id = op.getUpdate().getId(); - throw AgException.forbidden("%s operation on '%s' with id '%s' is forbidden", + + Object id = idForErrorReport(op); + + throw AgException.forbidden("%s of %s%s was blocked by authorization rules", op.getType(), - op.getUpdate().getEntity().getName(), - id == null ? "" : id.size() == 1 ? id.values().iterator().next() : id); + op.getEntity().getName(), + id == null ? "" : " with id of " + id); } } } + + static Object idForErrorReport(ChangeOperation op) { + + // different operations provide different source + + if (op.getUpdate() != null) { + Map updateId = op.getUpdate().getId(); + if (updateId != null) { + return updateId.size() == 1 ? updateId.values().iterator().next() : updateId; + } + } + + if (op.getObject() != null) { + Object id = op.getEntity().getIdReader().value(op.getObject()); + return id instanceof Map && ((Map) id).size() == 1 ? ((Map) id).values().iterator().next() : id; + } + + return null; + } } diff --git a/agrest-engine/src/main/java/io/agrest/runtime/processor/update/ChangeOperation.java b/agrest-engine/src/main/java/io/agrest/runtime/processor/update/ChangeOperation.java index 005e8606d..27397dbe4 100644 --- a/agrest-engine/src/main/java/io/agrest/runtime/processor/update/ChangeOperation.java +++ b/agrest-engine/src/main/java/io/agrest/runtime/processor/update/ChangeOperation.java @@ -1,6 +1,7 @@ package io.agrest.runtime.processor.update; import io.agrest.EntityUpdate; +import io.agrest.meta.AgEntity; import java.util.Objects; @@ -13,15 +14,21 @@ public class ChangeOperation { private final ChangeOperationType type; + private final AgEntity entity; private final T object; private final EntityUpdate update; - public ChangeOperation(ChangeOperationType type, T object, EntityUpdate update) { + public ChangeOperation(ChangeOperationType type, AgEntity entity, T object, EntityUpdate update) { this.type = Objects.requireNonNull(type); + this.entity = entity; this.object = object; this.update = update; } + public AgEntity getEntity() { + return entity; + } + public ChangeOperationType getType() { return type; }