Repository: gisaia/ARLAS-server (language: python)

Issue:
API: Add a mean to update collection visibility to organisations and public visibility
With ARLAS IAM, it becomes possible to "share" a collection with an organisation. Sharing is possible at the [collection creation time](https://docs.arlas.io/reference/#add-a-collection-reference), but it is not possible afterwards, except by posting the whole collection description.

To facilitate the sharing capability, an update endpoint for the organisations of an collection would be welcome. Three concepts are related:
- owner
- organisations allowed to access the collection
- whether the collection is public or not
The endpoint should allow to update the two later ones. The first one could be a security breach if exposed at the same level as the two later ones.



Discussion / hints:

Fix patch:
diff --git a/arlas-commons/src/main/resources/roles.yaml b/arlas-commons/src/main/resources/roles.yaml
index cba001d54..4e57e5caf 100644
--- a/arlas-commons/src/main/resources/roles.yaml
+++ b/arlas-commons/src/main/resources/roles.yaml
@@ -64,7 +64,7 @@ technicalRoles:
     - "Building dashboards in ARLAS"
     permissions:
     - r:collections:GET
-    - r:collections/.*:PUT,DELETE
+    - r:collections/.*:PATCH,PUT,DELETE
     - r:collections/_export:GET
     - r:collections/_import:POST
     - r:persist/resource/.*:PUT,POST,DELETE
@@ -94,7 +94,7 @@ technicalRoles:
     - "M2M account for importing collections"
     permissions:
     - r:collections:GET
-    - r:collections/.*:PUT,DELETE
+    - r:collections/.*:PATCH,PUT,DELETE
     - r:collections/_import:POST
     - r:organisations/.*:GET
     - r:persist/resource/.*:POST,PUT
diff --git a/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReference.java b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReference.java
index d78897025..8bec88055 100644
--- a/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReference.java
+++ b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReference.java
@@ -21,9 +21,11 @@
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
+import java.io.Serial;
 import java.io.Serializable;
 
 public class CollectionReference implements Serializable {
+    @Serial
     private static final long serialVersionUID = 2270763501550669101L;
 
     public static final String COLLECTION_NAME = "collection_name";
@@ -31,7 +33,6 @@ public class CollectionReference implements Serializable {
     public static final String ID_PATH = "id_path";
     public static final String GEOMETRY_PATH = "geometry_path";
     public static final String CENTROID_PATH = "centroid_path";
-    public static final String H3_PATH = "h3_path";
     public static final String TIMESTAMP_PATH = "timestamp_path";
     public static final String TIMESTAMP_FORMAT = "timestamp_format";
     public static final String DEFAULT_TIMESTAMP_FORMAT = "strict_date_optional_time||epoch_millis";
diff --git a/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceParameters.java b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceParameters.java
index 6aff2955a..1ce22231b 100644
--- a/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceParameters.java
+++ b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceParameters.java
@@ -52,9 +52,6 @@ public class CollectionReferenceParameters implements Serializable {
     @JsonProperty(value = CollectionReference.CENTROID_PATH, required = true)
     public String centroidPath;
 
-    @JsonProperty(value = CollectionReference.H3_PATH)
-    public String h3Path;
-
     @NotEmpty
     @JsonProperty(value = CollectionReference.TIMESTAMP_PATH, required = true)
     public String timestampPath;
diff --git a/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceUpdate.java b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceUpdate.java
new file mode 100644
index 000000000..2b58aed1f
--- /dev/null
+++ b/arlas-core/src/main/java/io/arlas/server/core/model/CollectionReferenceUpdate.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to Gisaïa under one or more contributor
+ * license agreements. See the NOTICE.txt file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Gisaïa licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package io.arlas.server.core.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+import static io.arlas.server.core.model.CollectionReference.ORGANISATIONS_PUBLIC;
+import static io.arlas.server.core.model.CollectionReference.ORGANISATIONS_SHARED;
+
+public class CollectionReferenceUpdate {
+    @JsonProperty(value = ORGANISATIONS_PUBLIC, required = false)
+    public Boolean isPublic;
+    @JsonProperty(value = ORGANISATIONS_SHARED, required = false)
+    public List<String> sharedWith;
+
+    public CollectionReferenceUpdate() {
+    }
+}
diff --git a/arlas-core/src/main/java/io/arlas/server/core/services/CollectionReferenceService.java b/arlas-core/src/main/java/io/arlas/server/core/services/CollectionReferenceService.java
index 81f4710c6..5aa1d9ab5 100644
--- a/arlas-core/src/main/java/io/arlas/server/core/services/CollectionReferenceService.java
+++ b/arlas-core/src/main/java/io/arlas/server/core/services/CollectionReferenceService.java
@@ -108,6 +108,23 @@ public CollectionReference putCollectionReference(CollectionReference collection
         return collectionReference;
     }
 
+    public CollectionReference updateCollectionReference(String collection,
+                                                         String organisations,
+                                                         String columnFilter,
+                                                         boolean isPublic,
+                                                         List<String> sharedWith)
+            throws ArlasException {
+        CollectionReference collectionReference = getCollectionReference(collection, Optional.ofNullable(organisations));
+        ColumnFilterUtil.assertCollectionsAllowed(Optional.ofNullable(columnFilter), List.of(collectionReference));
+        checkIfAllowedForOrganisations(collectionReference, Optional.ofNullable(organisations), true);
+        collectionReference.params.collectionOrganisations.isPublic = isPublic;
+        collectionReference.params.collectionOrganisations.sharedWith = sharedWith;
+        putCollectionReferenceWithDao(collectionReference);
+        cacheManager.removeCollectionReference(collectionReference.collectionName);
+        cacheManager.removeMapping(collectionReference.params.indexName);
+        return collectionReference;
+    }
+
     public List<CollectionReferenceDescription> describeAllCollections(List<CollectionReference> collectionReferenceList,
                                                                        Optional<String> columnFilter) throws CollectionUnavailableException {
 
diff --git a/arlas-rest/src/main/java/io/arlas/server/rest/collections/CollectionService.java b/arlas-rest/src/main/java/io/arlas/server/rest/collections/CollectionService.java
index 33d361414..f45a5e15b 100644
--- a/arlas-rest/src/main/java/io/arlas/server/rest/collections/CollectionService.java
+++ b/arlas-rest/src/main/java/io/arlas/server/rest/collections/CollectionService.java
@@ -33,7 +33,6 @@
 import io.arlas.server.core.model.*;
 import io.arlas.server.core.services.CollectionReferenceService;
 import io.arlas.server.core.utils.CheckParams;
-import io.arlas.server.core.utils.CollectionUtil;
 import io.arlas.server.core.utils.ColumnFilterUtil;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -45,6 +44,8 @@
 import javax.validation.Valid;
 import javax.validation.constraints.NotNull;
 import javax.ws.rs.*;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 import java.io.IOException;
@@ -158,12 +159,11 @@ public Response importCollections(
         removeMetacollection(collections);
         Set<String> allowedCollections = ColumnFilterUtil.getAllowedCollections(Optional.ofNullable(columnFilter));
         for (CollectionReference collection : collections) {
-            collectionReferenceService.checkIfAllowedForOrganisations(collection, Optional.ofNullable(organisations), true);
             for (String c : allowedCollections) {
                 if ((c.endsWith("*") && collection.collectionName.startsWith(c.substring(0, c.indexOf("*"))))
                         || collection.collectionName.equals(c)) {
                     try {
-                        savedCollections.add(save(collection.collectionName, collection.params, true));
+                        savedCollections.add(save(collection.collectionName, collection.params, true, organisations));
                     } catch (Exception e) {
                         throw new ArlasException(e.getMessage());
                     }
@@ -245,10 +245,14 @@ public Response get(
             @ApiResponse(code = 404, message = "Not Found Error.", response = Error.class),
             @ApiResponse(code = 500, message = "Arlas Server Error.", response = Error.class)})
     public Response put(
+            @ApiParam(hidden = true)
+            @HeaderParam(value = ARLAS_ORGANISATION) String organisations,
+
             @ApiParam(name = "collection",
                     value = "collection",
                     required = true)
             @PathParam(value = "collection") String collection,
+
             @ApiParam(name = "collectionParams",
                     value = "collectionParams",
                     required = true)
@@ -269,10 +273,60 @@ public Response put(
         if (collection != null && collection.equals(META_COLLECTION_NAME)) {
             throw new NotAllowedException("'" + META_COLLECTION_NAME + "' is not allowed as a name for collections");
         }
-        return ResponseFormatter.getResultResponse(save(collection, collectionReferenceParameters, checkFields == null ? Boolean.TRUE : checkFields));
+        return ResponseFormatter.getResultResponse(save(collection, collectionReferenceParameters,
+                checkFields == null ? Boolean.TRUE : checkFields, organisations));
+    }
+
+    @Timed
+    @Path("{collection}/organisations")
+    @PATCH
+    @Produces(UTF8JSON)
+    @Consumes(UTF8JSON)
+    @ApiOperation(
+            value = "Update a collection reference's organisations attribute.",
+            produces = UTF8JSON,
+            notes = "Update a collection reference's organisations attribute.",
+            consumes = UTF8JSON,
+            response = CollectionReference.class
+    )
+    @ApiResponses(value = {@ApiResponse(code = 200, message = "Successful operation", response = CollectionReference.class),
+            @ApiResponse(code = 400, message = "JSON parameter malformed.", response = Error.class),
+            @ApiResponse(code = 404, message = "Not Found Error.", response = Error.class),
+            @ApiResponse(code = 500, message = "Arlas Server Error.", response = Error.class)})
+    public Response patch(
+            @Context HttpHeaders headers,
+            @ApiParam(name = "collection",
+                    value = "collection",
+                    required = true)
+            @PathParam(value = "collection") String collection,
+
+            @ApiParam(name = "collectionParamsUpdate",
+                    value = "collectionParamsUpdate",
+                    required = true)
+            @NotNull CollectionReferenceUpdate cru,
+
+            @ApiParam(hidden = true)
+            @HeaderParam(value = COLUMN_FILTER) String columnFilter,
+
+            @ApiParam(hidden = true)
+            @HeaderParam(value = ARLAS_ORGANISATION) String organisations,
+            // --------------------------------------------------------
+            // ----------------------- FORM -----------------------
+            // --------------------------------------------------------
+            @ApiParam(name = "pretty",
+                    value = Documentation.FORM_PRETTY,
+                    defaultValue = "false")
+            @QueryParam(value = "pretty") Boolean pretty
+
+    ) throws ArlasException {
+        if (collection != null && collection.equals(META_COLLECTION_NAME)) {
+            throw new NotAllowedException("'" + META_COLLECTION_NAME + "' cannot be updated");
+        }
+        return ResponseFormatter.getResultResponse(collectionReferenceService.updateCollectionReference(collection, organisations, columnFilter, cru.isPublic, cru.sharedWith));
     }
 
-    public CollectionReference save(String collection, CollectionReferenceParameters collectionReferenceParameters, Boolean checkFields) throws ArlasException {
+    public CollectionReference save(String collection, CollectionReferenceParameters collectionReferenceParameters,
+                                    Boolean checkFields, String organisations) throws ArlasException {
         CollectionReference collectionReference = new CollectionReference(collection, collectionReferenceParameters);
         setDefaultInspireParameters(collectionReference);
         if (inspireConfigurationEnabled) {
@@ -280,6 +334,7 @@ public CollectionReference save(String collection, CollectionReferenceParameters
             CheckParams.checkInvalidDublinCoreElementsForInspire(collectionReference);
         }
         CheckParams.checkInvalidInspireParameters(collectionReference);
+        collectionReferenceService.checkIfAllowedForOrganisations(collectionReference, Optional.ofNullable(organisations), true);
         return collectionReferenceService.putCollectionReference(collectionReference, checkFields);
     }
 
@@ -329,26 +384,26 @@ private void setDefaultInspireParameters(CollectionReference collectionReference
         if (collectionReference.params.inspire == null) {
             collectionReference.params.inspire = new Inspire();
         }
-        if (collectionReference.params.inspire.keywords == null ||collectionReference.params.inspire.keywords.size() == 0) {
+        if (collectionReference.params.inspire.keywords == null || collectionReference.params.inspire.keywords.isEmpty()) {
             collectionReference.params.inspire.keywords = new ArrayList<>();
             Keyword k = new Keyword();
             k.value = collectionReference.collectionName;
             collectionReference.params.inspire.keywords.add(k);
         }
-        if (collectionReference.params.inspire.inspireUseConditions == null || collectionReference.params.inspire.inspireUseConditions.equals("")) {
+        if (collectionReference.params.inspire.inspireUseConditions == null || collectionReference.params.inspire.inspireUseConditions.isEmpty()) {
             collectionReference.params.inspire.inspireUseConditions = "no conditions apply";
         }
         if (collectionReference.params.inspire.inspireURI == null) {
             collectionReference.params.inspire.inspireURI = new InspireURI();
         }
-        if (collectionReference.params.inspire.inspireURI.code == null || collectionReference.params.inspire.inspireURI.code.equals("")) {
+        if (collectionReference.params.inspire.inspireURI.code == null || collectionReference.params.inspire.inspireURI.code.isEmpty()) {
             collectionReference.params.inspire.inspireURI.code = collectionReference.params.dublinCoreElementName.identifier;
         }
-        if (collectionReference.params.inspire.inspireURI.namespace == null || collectionReference.params.inspire.inspireURI.namespace.equals("")) {
+        if (collectionReference.params.inspire.inspireURI.namespace == null || collectionReference.params.inspire.inspireURI.namespace.isEmpty()) {
             collectionReference.params.inspire.inspireURI.namespace = "ARLAS." + collectionReference.collectionName.toUpperCase();
         }
         //a default language must be specified
-        if (collectionReference.params.inspire.languages == null || collectionReference.params.inspire.languages.size() == 0) {
+        if (collectionReference.params.inspire.languages == null || collectionReference.params.inspire.languages.isEmpty()) {
             collectionReference.params.inspire.languages = new ArrayList<>();
             collectionReference.params.inspire.languages.add("eng");
         }


Test patch:
diff --git a/arlas-tests/src/test/java/io/arlas/server/tests/rest/collections/CollectionServiceIT.java b/arlas-tests/src/test/java/io/arlas/server/tests/rest/collections/CollectionServiceIT.java
index 72f7d677b..095ed6d8d 100644
--- a/arlas-tests/src/test/java/io/arlas/server/tests/rest/collections/CollectionServiceIT.java
+++ b/arlas-tests/src/test/java/io/arlas/server/tests/rest/collections/CollectionServiceIT.java
@@ -360,6 +360,55 @@ public void test08WithCollectionFilter() throws Exception {
 
     }
 
+    @Test
+    public void test09Organisations() throws Exception {
+        Map<String, Object> jsonAsMap = getJsonAsMap("bar.com", null, false);
+        jsonAsMap.put(CollectionReference.INSPIRE_PATH, getInspireJsonAsMap());
+        jsonAsMap.put(CollectionReference.DUBLIN_CORE_PATH, getDublinJsonAsMap());
+
+        // PUT new collection
+        given().contentType("application/json")
+                .body(jsonAsMap)
+                .when().put(arlasPath + "collections/bar")
+                .then().statusCode(200);
+
+        // GET collection
+        when().get(arlasPath + "collections/bar")
+                .then().statusCode(200)
+                .body("collection_name", equalTo("bar"))
+                .body("params.index_name", equalTo(DataSetTool.DATASET_INDEX_NAME))
+                .body("params.id_path", equalTo(DataSetTool.DATASET_ID_PATH))
+                .body("params.geometry_path", equalTo(DataSetTool.DATASET_GEOMETRY_PATH))
+                .body("params.centroid_path", equalTo(DataSetTool.DATASET_CENTROID_PATH))
+                .body("params.timestamp_path", equalTo(DataSetTool.DATASET_TIMESTAMP_PATH))
+                .body("params.exclude_fields", equalTo(DataSetTool.DATASET_EXCLUDE_FIELDS))
+                .body("params.exclude_wfs_fields", equalTo(DataSetTool.DATASET_EXCLUDE_WFS_FIELDS))
+                .body("params.organisations.owner", equalTo("bar.com"))
+                .body("params.organisations.shared", hasSize(0))
+                .body("params.organisations.public", equalTo(Boolean.FALSE));
+
+        // PATCH collection
+        given().contentType("application/json")
+                .body(getOrgAsMap(null, List.of("foo.com"), Boolean.TRUE))
+                .when().patch(arlasPath + "collections/bar/organisations")
+                .then().statusCode(200)
+                .body("collection_name", equalTo("bar"))
+                .body("params.index_name", equalTo(DataSetTool.DATASET_INDEX_NAME))
+                .body("params.id_path", equalTo(DataSetTool.DATASET_ID_PATH))
+                .body("params.geometry_path", equalTo(DataSetTool.DATASET_GEOMETRY_PATH))
+                .body("params.centroid_path", equalTo(DataSetTool.DATASET_CENTROID_PATH))
+                .body("params.timestamp_path", equalTo(DataSetTool.DATASET_TIMESTAMP_PATH))
+                .body("params.exclude_fields", equalTo(DataSetTool.DATASET_EXCLUDE_FIELDS))
+                .body("params.exclude_wfs_fields", equalTo(DataSetTool.DATASET_EXCLUDE_WFS_FIELDS))
+                .body("params.organisations.owner", equalTo("bar.com"))
+                .body("params.organisations.shared", hasItems("foo.com"))
+                .body("params.organisations.public", equalTo(Boolean.TRUE));
+
+        // DELETE collection
+        when().delete(arlasPath + "collections/bar")
+                .then().statusCode(200);
+
+    }
 
     private void handleInvalidCollectionParameters(ValidatableResponse then) throws Exception {
         then.statusCode(400);
@@ -380,6 +429,10 @@ private ValidatableResponse put(Map<String, Object> jsonAsMap) {
     }
 
     private Map<String, Object> getJsonAsMap() {
+        return getJsonAsMap(null, null, null);
+    }
+
+    private Map<String, Object> getJsonAsMap(String orgOwner, List<String> orgShared, Boolean isPublic) {
         Map<String, Object> jsonAsMap = new HashMap<>();
         jsonAsMap.put(CollectionReference.INDEX_NAME, DataSetTool.DATASET_INDEX_NAME);
         jsonAsMap.put(CollectionReference.ID_PATH, DataSetTool.DATASET_ID_PATH);
@@ -388,9 +441,22 @@ private Map<String, Object> getJsonAsMap() {
         jsonAsMap.put(CollectionReference.TIMESTAMP_PATH, DataSetTool.DATASET_TIMESTAMP_PATH);
         jsonAsMap.put(CollectionReference.EXCLUDE_FIELDS, DataSetTool.DATASET_EXCLUDE_FIELDS);
         jsonAsMap.put(CollectionReference.EXCLUDE_WFS_FIELDS, DataSetTool.DATASET_EXCLUDE_WFS_FIELDS);
+        if (orgOwner != null) {
+            jsonAsMap.put(CollectionReference.ORGANISATIONS, getOrgAsMap(orgOwner, orgShared, isPublic));
+        }
         return jsonAsMap;
     }
 
+    private Map<String, Object> getOrgAsMap(String orgOwner, List<String> orgShared, Boolean isPublic) {
+        Map<String, Object> orgAsMap = new HashMap<>();
+        if (orgOwner != null) {
+            orgAsMap.put(CollectionReference.ORGANISATIONS_OWNER, orgOwner);
+        }
+        orgAsMap.put(CollectionReference.ORGANISATIONS_SHARED, Objects.requireNonNullElse(orgShared, Collections.emptyList()));
+        orgAsMap.put(CollectionReference.ORGANISATIONS_PUBLIC, Objects.requireNonNullElse(isPublic, Boolean.FALSE));
+        return orgAsMap;
+    }
+
     private Object getCollectionDescriptionJsonAsMap() {
         Map<String, Object> jsonAsMap = new HashMap<>();
         jsonAsMap.put(CollectionReference.COLLECTION_DISPLAY_NAME, DataSetTool.DATASET_COLLECTION_DISPLAY_NAME);