diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 66530cfaed..5d6ab306b2 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -66,6 +66,7 @@ import org.opensearch.Version; import org.opensearch.action.ActionRequest; import org.opensearch.action.ActionResponse; +import org.opensearch.action.search.PitService; import org.opensearch.action.search.SearchScrollAction; import org.opensearch.action.support.ActionFilter; import org.opensearch.client.Client; @@ -1160,13 +1161,15 @@ public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; private static RemoteClusterService remoteClusterService; private static IndicesService indicesService; + private static PitService pitService; @Inject public GuiceHolder(final RepositoriesService repositoriesService, - final TransportService remoteClusterService, IndicesService indicesService) { + final TransportService remoteClusterService, IndicesService indicesService, PitService pitService) { GuiceHolder.repositoriesService = repositoriesService; GuiceHolder.remoteClusterService = remoteClusterService.getRemoteClusterService(); GuiceHolder.indicesService = indicesService; + GuiceHolder.pitService = pitService; } public static RepositoriesService getRepositoriesService() { @@ -1180,6 +1183,10 @@ public static RemoteClusterService getRemoteClusterService() { public static IndicesService getIndicesService() { return indicesService; } + + public static PitService getPitService() { + return pitService; + } @Override public void close() { diff --git a/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java new file mode 100644 index 0000000000..9a31b73c4b --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.admin.indices.segments.PitSegmentsRequest; +import org.opensearch.action.search.DeletePitRequest; +import org.opensearch.action.search.GetAllPitNodesRequest; +import org.opensearch.action.search.GetAllPitNodesResponse; +import org.opensearch.action.search.ListPitInfo; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.security.OpenSearchSecurityPlugin; +import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.securityconf.SecurityRoles; +import org.opensearch.security.user.User; + +/** + * This class evaluates privileges for point in time (Delete and List all) operations + */ +public class PitPrivilegesEvaluator { + + protected final Logger log = LogManager.getLogger(this.getClass()); + private boolean isDebugEnabled = log.isDebugEnabled(); + + public PrivilegesEvaluatorResponse evaluate(final ActionRequest request, final ClusterService clusterService, + final User user, final SecurityRoles securityRoles, final String action, + final IndexNameExpressionResolver resolver, + boolean dnfOfEmptyResultsEnabled, final PrivilegesEvaluatorResponse presponse) { + + // Skip custom evaluation for "NodesGetAllPITs" action, since it fetches all PITs across the cluster + // for privilege evaluation - still this action will be evaluated in the generic PrivilegesEvaluator flow + if(action.startsWith("cluster:admin/point_in_time")) { + return presponse; + } + if (request instanceof GetAllPitNodesRequest) { + return handleGetAllPitsAccess(request, clusterService, user, securityRoles, + action, resolver, dnfOfEmptyResultsEnabled, presponse); + } else if (request instanceof DeletePitRequest) { + DeletePitRequest deletePitRequest = (DeletePitRequest) request; + return handleExplicitPitsAccess(deletePitRequest.getPitIds(), clusterService, user, securityRoles, + action, resolver, presponse); + } else if (request instanceof PitSegmentsRequest) { + PitSegmentsRequest pitSegmentsRequest = (PitSegmentsRequest) request; + return handleExplicitPitsAccess(pitSegmentsRequest.getPitIds(), clusterService, user, securityRoles, + action, resolver, presponse); + } + return presponse; + } + + /** + * Handle access for Get All PITs access + */ + private PrivilegesEvaluatorResponse handleGetAllPitsAccess(final ActionRequest request, final ClusterService clusterService, + final User user, SecurityRoles securityRoles, final String action, + IndexNameExpressionResolver resolver, + boolean dnfOfEmptyResultsEnabled, PrivilegesEvaluatorResponse presponse) { + List pitInfos = ((GetAllPitNodesRequest) request).getGetAllPitNodesResponse().getPitInfos(); + // if cluster has no PITs, then allow the operation to pass with empty response if dnfOfEmptyResultsEnabled + // config property is true, otherwise fail the operation + if(pitInfos.isEmpty()) { + if(dnfOfEmptyResultsEnabled) { + presponse.allowed = true; + presponse.markComplete(); + } + return presponse; + } + List pitIds = new ArrayList<>(); + pitIds.addAll(pitInfos.stream().map(ListPitInfo::getPitId).collect(Collectors.toList())); + Map pitToIndicesMap = OpenSearchSecurityPlugin.GuiceHolder.getPitService().getIndicesForPits(pitIds); + Map pitToPitInfoMap = new HashMap<>(); + + for(ListPitInfo pitInfo : pitInfos) { + pitToPitInfoMap.put(pitInfo.getPitId(), pitInfo); + } + List permittedPits = new ArrayList<>(); + + Set allPitIndices = new HashSet<>(); + for(String[] indices: pitToIndicesMap.values()) { + allPitIndices.addAll(Arrays.asList(indices)); + } + final Set allPermittedPitIndices = getPermittedIndices(allPitIndices, clusterService, user, + securityRoles, action, resolver); + + for (String pitId : pitIds) { + final String[] indices = pitToIndicesMap.get(pitId); + final HashSet pitIndicesSet = new HashSet<>(Arrays.asList(indices)); + if(isDebugEnabled) { + log.debug("Evaluating PIT ID : " + pitId ); + } + + if (allPermittedPitIndices.containsAll(pitIndicesSet)) { + if(isDebugEnabled) { + log.debug(" Permitting PIT ID : " + pitId); + } + permittedPits.add(pitToPitInfoMap.get(pitId)); + } + } + if (permittedPits.size() > 0) { + ((GetAllPitNodesRequest) request).setGetAllPitNodesResponse(new GetAllPitNodesResponse(permittedPits, + ((GetAllPitNodesRequest) request).getGetAllPitNodesResponse())); + presponse.allowed = true; + presponse.markComplete(); + } + return presponse; + } + + /** + * Handle access for delete operation / pit segments operation where PIT IDs are explicitly passed + */ + private PrivilegesEvaluatorResponse handleExplicitPitsAccess(List pitIds, ClusterService clusterService, + User user, SecurityRoles securityRoles, final String action, + IndexNameExpressionResolver resolver, PrivilegesEvaluatorResponse presponse) { + Map pitToIndicesMap = OpenSearchSecurityPlugin. + GuiceHolder.getPitService().getIndicesForPits(pitIds); + Set pitIndices = new HashSet<>(); + // add indices across all PITs to a set and evaluate if user has access to all indices + for(String[] indices: pitToIndicesMap.values()) { + pitIndices.addAll(Arrays.asList(indices)); + } + Set allPermittedIndices = getPermittedIndices(pitIndices, clusterService, user, + securityRoles, action, resolver); + // In this case, PIT IDs are explicitly passed. + // So, only if user has access to all PIT's indices, allow delete operation, otherwise fail. + if(pitIndices.size() == allPermittedIndices.size()) { + presponse.allowed = true; + presponse.markComplete(); + } + return presponse; + } + + /** + * This method returns list of permitted indices for the PIT indices passed + */ + private Set getPermittedIndices(Set pitIndices, ClusterService clusterService, + User user, SecurityRoles securityRoles, final String action, + IndexNameExpressionResolver resolver) { + final ImmutableSet pitImmutableIndices = ImmutableSet.copyOf(pitIndices); + final IndexResolverReplacer.Resolved pitResolved = + new IndexResolverReplacer.Resolved(pitImmutableIndices, pitImmutableIndices, pitImmutableIndices, + ImmutableSet.of(), SearchRequest.DEFAULT_INDICES_OPTIONS); + return securityRoles.reduce(pitResolved, + user, new String[]{action}, resolver, clusterService); + } +} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index fd1b26d388..10662e6f40 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -130,6 +130,7 @@ public class PrivilegesEvaluator { private final SecurityIndexAccessEvaluator securityIndexAccessEvaluator; private final ProtectedIndexAccessEvaluator protectedIndexAccessEvaluator; private final TermsAggregationEvaluator termsAggregationEvaluator; + private final PitPrivilegesEvaluator pitPrivilegesEvaluator; private final boolean dlsFlsEnabled; private final boolean dfmEmptyOverwritesAll; private DynamicConfigModel dcm; @@ -158,6 +159,7 @@ public PrivilegesEvaluator(final ClusterService clusterService, final ThreadPool securityIndexAccessEvaluator = new SecurityIndexAccessEvaluator(settings, auditLog, irr); protectedIndexAccessEvaluator = new ProtectedIndexAccessEvaluator(settings, auditLog); termsAggregationEvaluator = new TermsAggregationEvaluator(); + pitPrivilegesEvaluator = new PitPrivilegesEvaluator(); this.namedXContentRegistry = namedXContentRegistry; this.dlsFlsEnabled = dlsFlsEnabled; this.dfmEmptyOverwritesAll = settings.getAsBoolean(ConfigConstants.SECURITY_DFM_EMPTY_OVERRIDES_ALL, false); @@ -282,6 +284,12 @@ public PrivilegesEvaluatorResponse evaluate(final User user, String action0, fin return presponse; } + // check access for point in time requests + if(pitPrivilegesEvaluator.evaluate(request, clusterService, user, securityRoles, + action0, resolver, dcm.isDnfofForEmptyResultsEnabled(), presponse).isComplete()) { + return presponse; + } + final boolean dnfofEnabled = dcm.isDnfofEnabled(); final boolean isTraceEnabled = log.isTraceEnabled(); diff --git a/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java b/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java index e0eddf9993..d2d0685860 100644 --- a/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java +++ b/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java @@ -370,11 +370,11 @@ public final static class Resolved { private final boolean isLocalAll; private final IndicesOptions indicesOptions; - private Resolved(final ImmutableSet aliases, - final ImmutableSet allIndices, - final ImmutableSet originalRequested, - final ImmutableSet remoteIndices, - IndicesOptions indicesOptions) { + public Resolved(final ImmutableSet aliases, + final ImmutableSet allIndices, + final ImmutableSet originalRequested, + final ImmutableSet remoteIndices, + IndicesOptions indicesOptions) { this.aliases = aliases; this.allIndices = allIndices; this.originalRequested = originalRequested; diff --git a/src/main/resources/static_config/static_action_groups.yml b/src/main/resources/static_config/static_action_groups.yml index d0ce7613a2..85af25e9fc 100644 --- a/src/main/resources/static_config/static_action_groups.yml +++ b/src/main/resources/static_config/static_action_groups.yml @@ -233,8 +233,10 @@ manage_point_in_time: static: true allowed_actions: - "indices:data/read/point_in_time/create" - - "cluster:admin/point_in_time/delete" - - "cluster:admin/point_in_time/read*" + - "indices:data/read/point_in_time/delete" + - "indices:data/read/point_in_time/readall" + - "indices:data/read/search" + - "cluster:admin/point_in_time/read_from_nodes" - "indices:monitor/point_in_time/segments" - type: "cluster" + type: "index" description: "Manage point in time actions" diff --git a/src/test/java/org/opensearch/security/PitIntegrationTests.java b/src/test/java/org/opensearch/security/PitIntegrationTests.java new file mode 100644 index 0000000000..0c8b5aa86b --- /dev/null +++ b/src/test/java/org/opensearch/security/PitIntegrationTests.java @@ -0,0 +1,177 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security; + +import org.apache.http.HttpStatus; +import org.junit.Assert; +import org.junit.Test; + +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.client.Client; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.security.test.SingleClusterTest; +import org.opensearch.security.test.helper.rest.RestHelper; + +/** + * Integration tests to test point in time APIs permission model + */ +public class PitIntegrationTests extends SingleClusterTest { + + @Test + public void pitCreateWithDeleteAll() throws Exception { + setup(); + RestHelper rh = nonSslRestHelper(); + + // Create two indices + try (Client tc = getClient()) { + tc.index(new IndexRequest("pit_1").id("1").setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE). + source("{\"content\":1}", XContentType.JSON)).actionGet(); + tc.index(new IndexRequest("pit_2").id("2").setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE). + source("{\"content\":2}", XContentType.JSON)).actionGet(); + } + + RestHelper.HttpResponse resc; + + // Create point in time in index should be successful since the user has permission for index + resc = rh.executePostRequest("/pit_1/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + String pitId1 = resc.findValueInJson("pit_id"); + + // Create point in time in index for which the user does not have permission + resc = rh.executePostRequest("/pit_2/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // Create point in time in index for which the user has permission for + resc = rh.executePostRequest("/pit_2/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + + String pitId2 = resc.findValueInJson("pit_id"); + + // Delete all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId1, resc.findValueInJson("pits[0].pit_id")); + Assert.assertEquals("true", resc.findValueInJson("pits[0].successful")); + + // Delete all PITs should throw error since there are no PITs for which the user has access for + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // Delete all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId2, resc.findValueInJson("pits[0].pit_id")); + Assert.assertEquals("true", resc.findValueInJson("pits[0].successful")); + + + // Delete all PITs throws forbidden error since there are no PITs in the cluster + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + } + + @Test + public void pitCreateWithGetAll() throws Exception { + setup(); + RestHelper rh = nonSslRestHelper(); + + // Create two indices + try (Client tc = getClient()) { + tc.index(new IndexRequest("pit_1").id("1").setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE). + source("{\"content\":1}", XContentType.JSON)).actionGet(); + tc.index(new IndexRequest("pit_2").id("2").setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE). + source("{\"content\":2}", XContentType.JSON)).actionGet(); + } + + RestHelper.HttpResponse resc; + + // Create point in time in index should be successful since the user has permission for index + resc = rh.executePostRequest("/pit_1/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + + String pitId1 = resc.findValueInJson("pit_id"); + + // Create point in time in index for which the user does not have permission + resc = rh.executePostRequest("/pit_2/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // Create point in time in index for which the user has permission for + resc = rh.executePostRequest("/pit_2/_search/point_in_time?keep_alive=100m", "", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + String pitId2 = resc.findValueInJson("pit_id"); + + // List all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeGetRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId1, resc.findValueInJson("pits[0].pit_id")); + + // PIT segments should work since there is atleast one PIT for which user has access for + resc = rh.executeGetRequest("/_cat/pit_segments/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + + // Delete all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId1, resc.findValueInJson("pits[0].pit_id")); + Assert.assertEquals("true", resc.findValueInJson("pits[0].successful")); + + // List all PITs should throw error since there are no PITs for which the user has access for + resc = rh.executeGetRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // PIT segments should throw error since there are PITs in system but no PIT for which user has access for + resc = rh.executeGetRequest("/_cat/pit_segments/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // List all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeGetRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId2, resc.findValueInJson("pits[0].pit_id")); + + // PIT segments should work since there is atleast one PIT for which user has access for + resc = rh.executeGetRequest("/_cat/pit_segments/_all", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + + // Delete all PITs should work since there is atleast one PIT for which user has access for + resc = rh.executeDeleteRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-2", "nagilum")); + Assert.assertEquals(HttpStatus.SC_OK, resc.getStatusCode()); + Assert.assertEquals(pitId2, resc.findValueInJson("pits[0].pit_id")); + Assert.assertEquals("true", resc.findValueInJson("pits[0].successful")); + + // List all PITs throws forbidden error since there are no PITs in the cluster + resc = rh.executeGetRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + + // PIT segments API throws forbidden error since there are no PITs in the cluster + resc = rh.executeGetRequest("/_search/point_in_time/_all", + encodeBasicHeader("pit-1", "nagilum")); + Assert.assertEquals(HttpStatus.SC_FORBIDDEN, resc.getStatusCode()); + } +} \ No newline at end of file diff --git a/src/test/resources/internal_users.yml b/src/test/resources/internal_users.yml index 99d821ce33..3344ecdad0 100644 --- a/src/test/resources/internal_users.yml +++ b/src/test/resources/internal_users.yml @@ -346,6 +346,12 @@ ds2: ds3: hash: $2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m #password is: nagilum +pit-1: + hash: $2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m + #password is: nagilum +pit-2: + hash: $2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m + #password is: nagilum hidden_test: hash: $2a$12$n5nubfWATfQjSYHiWtUyeOxMIxFInUHOAx8VMmGmxFNPGpaBmeB.m opendistro_security_roles: diff --git a/src/test/resources/roles.yml b/src/test/resources/roles.yml index 20e7c38cdb..c943613d2a 100644 --- a/src/test/resources/roles.yml +++ b/src/test/resources/roles.yml @@ -1128,7 +1128,35 @@ data_stream_3: - "*" allowed_actions: - "DATASTREAM_ALL" +point_in_time_1: + reserved: true + hidden: false + description: "Migrated from v6 (all types mapped)" + cluster_permissions: + - "cluster:admin/point_in_time/read_from_nodes" + index_permissions: + - index_patterns: + - "pit_1" + dls: null + fls: null + masked_fields: null + allowed_actions: + - "manage_point_in_time" +point_in_time_2: + reserved: true + hidden: false + description: "Migrated from v6 (all types mapped)" + cluster_permissions: + - "cluster:admin/point_in_time/read_from_nodes" + index_permissions: + - index_patterns: + - "pit_2" + dls: null + fls: null + masked_fields: null + allowed_actions: + - "manage_point_in_time" hidden_test: cluster_permissions: - SGS_CLUSTER_COMPOSITE_OPS diff --git a/src/test/resources/roles_mapping.yml b/src/test/resources/roles_mapping.yml index 9253b0c970..c5ce58af9b 100644 --- a/src/test/resources/roles_mapping.yml +++ b/src/test/resources/roles_mapping.yml @@ -413,6 +413,16 @@ data_stream_3: hidden: false users: - "ds3" +point_in_time_1: + reserved: false + hidden: false + users: + - "pit-1" +point_in_time_2: + reserved: false + hidden: false + users: + - "pit-2" sem-role: reserved: false hidden: false