From e3736ce173d69f86a0cda114e810e7b071d7e0a9 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Wed, 12 Nov 2025 17:34:16 +0100 Subject: [PATCH] NoSQL: authZ API, SPI, impl and store NoSQL base functionality for ACLs --- bom/build.gradle.kts | 5 + gradle/projects.main.properties | 5 + persistence/nosql/authz/README.md | 64 +++ persistence/nosql/authz/api/build.gradle.kts | 40 ++ .../persistence/nosql/authz/api/Acl.java | 57 +++ .../persistence/nosql/authz/api/AclChain.java | 37 ++ .../persistence/nosql/authz/api/AclEntry.java | 112 ++++++ .../nosql/authz/api/Constants.java | 143 +++++++ .../nosql/authz/api/PredefinedRoles.java | 29 ++ .../nosql/authz/api/Privilege.java | 158 ++++++++ .../nosql/authz/api/PrivilegeCheck.java | 52 +++ .../nosql/authz/api/PrivilegeSet.java | 119 ++++++ .../authz/api/PrivilegeSetJsonFilter.java | 32 ++ .../nosql/authz/api/Privileges.java | 76 ++++ .../api/src/main/resources/META-INF/beans.xml | 24 ++ persistence/nosql/authz/impl/build.gradle.kts | 60 +++ .../nosql/authz/impl/AclDeserializer.java | 49 +++ .../nosql/authz/impl/AclEntryBuilderImpl.java | 143 +++++++ .../persistence/nosql/authz/impl/AclImpl.java | 132 +++++++ .../nosql/authz/impl/AclSerializer.java | 42 ++ .../authz/impl/JacksonPrivilegesModule.java | 89 +++++ .../nosql/authz/impl/PrivilegeCheckImpl.java | 75 ++++ .../authz/impl/PrivilegeSetDeserializer.java | 59 +++ .../nosql/authz/impl/PrivilegeSetImpl.java | 368 ++++++++++++++++++ .../authz/impl/PrivilegeSetSerializer.java | 55 +++ .../nosql/authz/impl/PrivilegesImpl.java | 256 ++++++++++++ .../src/main/resources/META-INF/beans.xml | 24 ++ .../com.fasterxml.jackson.databind.Module | 20 + .../authz/impl/PrivilegesTestProvider.java | 84 ++++ .../authz/impl/PrivilegesTestRepository.java | 43 ++ .../nosql/authz/impl/TestAclEntryImpl.java | 73 ++++ .../nosql/authz/impl/TestAclImpl.java | 94 +++++ .../authz/impl/TestPrivilegeCheckImpl.java | 228 +++++++++++ .../authz/impl/TestPrivilegeSetImpl.java | 217 +++++++++++ .../nosql/authz/impl/TestPrivilegesImpl.java | 143 +++++++ persistence/nosql/authz/spi/build.gradle.kts | 43 ++ .../nosql/authz/spi/PrivilegeDefinition.java | 35 ++ .../nosql/authz/spi/PrivilegesMapping.java | 42 ++ .../nosql/authz/spi/PrivilegesProvider.java | 33 ++ .../nosql/authz/spi/PrivilegesRepository.java | 37 ++ .../nosql/authz/store-nosql/build.gradle.kts | 62 +++ .../store/nosql/PrivilegesMappingObj.java | 54 +++ .../store/nosql/PrivilegesRepositoryImpl.java | 97 +++++ .../nosql/PrivilegesRetainedIdentifier.java | 54 +++ .../src/main/resources/META-INF/beans.xml | 24 ++ ....polaris.persistence.nosql.api.obj.ObjType | 20 + .../nosql/TestPrivilegesRepositoryImpl.java | 62 +++ .../src/test/resources/logback-test.xml | 30 ++ .../src/test/resources/weld.properties | 21 + 49 files changed, 3821 insertions(+) create mode 100644 persistence/nosql/authz/README.md create mode 100644 persistence/nosql/authz/api/build.gradle.kts create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java create mode 100644 persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java create mode 100644 persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml create mode 100644 persistence/nosql/authz/impl/build.gradle.kts create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java create mode 100644 persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java create mode 100644 persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml create mode 100644 persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java create mode 100644 persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java create mode 100644 persistence/nosql/authz/spi/build.gradle.kts create mode 100644 persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java create mode 100644 persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java create mode 100644 persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java create mode 100644 persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java create mode 100644 persistence/nosql/authz/store-nosql/build.gradle.kts create mode 100644 persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java create mode 100644 persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java create mode 100644 persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java create mode 100644 persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml create mode 100644 persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType create mode 100644 persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java create mode 100644 persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml create mode 100644 persistence/nosql/authz/store-nosql/src/test/resources/weld.properties diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index ae4ff43781..14bff39e4e 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -49,6 +49,11 @@ dependencies { api(project(":polaris-nodes-spi")) api(project(":polaris-nodes-store-nosql")) + api(project(":polaris-persistence-nosql-authz-api")) + api(project(":polaris-persistence-nosql-authz-impl")) + api(project(":polaris-persistence-nosql-authz-spi")) + api(project(":polaris-persistence-nosql-authz-store-nosql")) + api(project(":polaris-persistence-nosql-realms-api")) api(project(":polaris-persistence-nosql-realms-impl")) api(project(":polaris-persistence-nosql-realms-spi")) diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index 244e647cd8..737c95a3ab 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -63,6 +63,11 @@ polaris-nodes-api=persistence/nosql/nodes/api polaris-nodes-impl=persistence/nosql/nodes/impl polaris-nodes-spi=persistence/nosql/nodes/spi polaris-nodes-store-nosql=persistence/nosql/nodes/store-nosql +# authz +polaris-persistence-nosql-authz-api=persistence/nosql/authz/api +polaris-persistence-nosql-authz-impl=persistence/nosql/authz/impl +polaris-persistence-nosql-authz-spi=persistence/nosql/authz/spi +polaris-persistence-nosql-authz-store-nosql=persistence/nosql/authz/store-nosql # realms polaris-persistence-nosql-realms-api=persistence/nosql/realms/api polaris-persistence-nosql-realms-impl=persistence/nosql/realms/impl diff --git a/persistence/nosql/authz/README.md b/persistence/nosql/authz/README.md new file mode 100644 index 0000000000..a6c6fe830d --- /dev/null +++ b/persistence/nosql/authz/README.md @@ -0,0 +1,64 @@ + + +# AuthZ framework with pluggable privileges + +Provides a framework and implementations pluggable privileges and privilege checks. + +## Privileges + +A privilege is globally identified by its name. Privileges can be inheritable (from its parents) or not. Multiple +privileges can be grouped together to a _composite_ privilege (think: `ALL_DML` having `SELECT`, `INSERT`, `UPDATE` and +`DELETE`) - a composite privilege matches, if all its individual privileges match. Multiple privileges can also be +grouped to an _alternative_ privilege, which matches if any of its individual privileges matches. + +Available privileges are provided by one or more `PrivilegeProvider`s, which are discovered at runtime. +Note: currently there is only one `ProvilegeProvider` that plugs in the Polaris privileges. + +## ACLs, ACL entries and ACL chains + +Each securable object can have its own ACL. ACLs consist of ACL entries, which define the _granted_ and _restricted_ +privileges by role name. The the number of roles is technically unbounded and the number of ACL entries can become +quite large. + +This framework implements [separation of duties](https://en.wikipedia.org/wiki/Separation_of_duties) ("SoD"), which is a +quite demanded functionality not just by large(r) user organizations. TL;DR _SoD_ allows "security administrators" to +grant and revoke privileges to other users, but not leverage those privileges themselves. + +The _effective_ set of privileges for a specific operation performed by a specific caller needs to be computed against +the target objects and their parents. _ACL chains_ are the vehicle to model this hierarchy and let the implementation +compute the set of _effective_ privileges based on the individual ACLs and roles. + +Note: Privilege checks and _SoD_ are currently not performed via this framework. + +## Jackson support & Storage friendly representation + +The persistable types `Acl`, `AclEntry`, and `PrivilegeSet` can all be serialized using Jackson. + +As the number of ACL entries can become quite large, space efficient serialization is quite important. The +implementation uses bit-set encoding when serializing `PrivilegeSet`s for persistence. + +## Code structure + +The code is structured into multiple modules. Consuming code should almost always pull in only the API module. + +* `polaris-authz-api` provides the necessary Java interfaces and immutable types. +* `polaris-authz-impl` provides the storage agnostic implementation. +* `polaris-authz-spi` provides the necessary interfaces to provide custom privileges and storage implementation. +* `polaris-authz-store-nosql` provides the storage implementation based on `polaris-persistence-nosql-api`. diff --git a/persistence/nosql/authz/api/build.gradle.kts b/persistence/nosql/authz/api/build.gradle.kts new file mode 100644 index 0000000000..f6908d50c7 --- /dev/null +++ b/persistence/nosql/authz/api/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ API" + +dependencies { + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java new file mode 100644 index 0000000000..a4111f1f25 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Acl.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public interface Acl { + + void entriesForRoleIds( + @Nonnull Set roleIds, @Nonnull Consumer aclEntryConsumer); + + void forEach(@Nonnull BiConsumer consumer); + + interface AclBuilder { + @CanIgnoreReturnValue + AclBuilder from(@Nonnull Acl instance); + + @CanIgnoreReturnValue + AclBuilder addEntry(@Nonnull String roleId, @Nonnull AclEntry entry); + + @CanIgnoreReturnValue + AclBuilder removeEntry(@Nonnull String roleId); + + /** + * Add, remove or update an {@linkplain AclEntry ACL entry} for a role. + * + *

The {@linkplain Consumer consumer} is called with an empty builder, if no ACL entry for + * the role exists, otherwise with a builder constructed from the existing entry. If the given + * {@linkplain Consumer consumer} removes all privileges from the ACL entry, the ACL entry will + * be removed. + */ + @CanIgnoreReturnValue + AclBuilder modify(@Nonnull String roleId, @Nonnull Consumer entry); + + Acl build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java new file mode 100644 index 0000000000..74e73d266c --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclChain.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import java.util.Optional; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** Container for an {@linkplain Acl ACL} of an individual entity and a pointer to its parent. */ +@PolarisImmutable +public interface AclChain { + @Value.Parameter(order = 1) + Acl acl(); + + @Value.Parameter(order = 2) + Optional parent(); + + static AclChain aclChain(Acl acl, Optional parent) { + return ImmutableAclChain.of(acl, parent); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java new file mode 100644 index 0000000000..d8a290990b --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/AclEntry.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Collection; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** + * An {@link Acl ACL} entry holds the {@linkplain PrivilegeSet sets} of granted and + * restricted ("separation of duties") {@linkplain Privilege privileges}. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutableAclEntry.class) +@JsonDeserialize(as = ImmutableAclEntry.class) +public interface AclEntry { + @Value.Parameter(order = 1) + @Value.Default + // The 'CUSTOM/valueFilter' combination is there to only include non-empty privilege sets + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = PrivilegeSetJsonFilter.class) + default PrivilegeSet granted() { + return PrivilegeSet.emptyPrivilegeSet(); + } + + @Value.Parameter(order = 2) + @Value.Default + // The 'CUSTOM/valueFilter' combination is there to only include non-empty privilege sets + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = PrivilegeSetJsonFilter.class) + default PrivilegeSet restricted() { + return PrivilegeSet.emptyPrivilegeSet(); + } + + @Value.NonAttribute + @JsonIgnore + default boolean isEmpty() { + return granted().isEmpty() && restricted().isEmpty(); + } + + interface AclEntryBuilder { + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder grant(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder revoke(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder restrict(@Nonnull PrivilegeSet privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Collection privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + AclEntryBuilder unrestrict(@Nonnull PrivilegeSet privileges); + + AclEntry build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java new file mode 100644 index 0000000000..ad54d6d117 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Constants.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import static java.util.Collections.emptyIterator; + +import jakarta.annotation.Nonnull; +import java.util.Collection; +import java.util.Iterator; + +final class Constants { + + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + private Constants() {} + + static final PrivilegeSet EMPTY_PRIVILEGE_SET = + new PrivilegeSet() { + @Override + public boolean contains(Privilege privilege) { + return false; + } + + @Override + public Iterator iterator(Privileges privileges) { + return emptyIterator(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public byte[] toByteArray() { + return EMPTY_BYTE_ARRAY; + } + + @Override + public boolean contains(Object o) { + return false; + } + + @Override + public boolean containsAll(@Nonnull Collection c) { + return false; + } + + @Override + public boolean containsAny(Iterable privilege) { + return false; + } + + @Override + public int size() { + return 0; + } + + @Override + @Nonnull + public Iterator iterator() { + return emptyIterator(); + } + + @Override + @Nonnull + public Object[] toArray() { + return new Object[0]; + } + + @Override + @Nonnull + public T[] toArray(T[] a) { + @SuppressWarnings("unchecked") + var r = (T[]) new Object[a.length]; + return r; + } + + @Override + public boolean add(Privilege privilege) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(@Nonnull Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof PrivilegeSet privilegeSet) { + return privilegeSet.isEmpty(); + } + return false; + } + + @Override + public int hashCode() { + return -1; + } + + @Override + public String toString() { + return "PrivilegeSet{}"; + } + }; +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java new file mode 100644 index 0000000000..758f80ee48 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PredefinedRoles.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +public final class PredefinedRoles { + private PredefinedRoles() {} + + /** Unauthenticated requests. */ + public static final String ANONYMOUS_ROLE = ""; + + /** All authenticated users. */ + public static final String PUBLIC_ROLE = ""; +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java new file mode 100644 index 0000000000..b26c244dd4 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privilege.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.Set; +import org.apache.polaris.immutables.PolarisImmutable; +import org.immutables.value.Value; + +/** + * Represents an individual or composite privilege. + * + *

Composite privileges consist of multiple individual privileges. + * + *

External representations, for example, when serialized as JSON, prefer composite privileges + * over individual ones. This means that if a {@linkplain PrivilegeSet privilege set} contains all + * privileges included in a composite privilege, only the composite privilege is serialized. If + * multiple composite privileges match, all matching ones are serialized. + */ +public interface Privilege { + String name(); + + Set resolved(); + + default boolean mustMatchAll() { + return true; + } + + interface IndividualPrivilege extends Privilege { + @Override + @Value.Auxiliary + default Set resolved() { + return Set.of(this); + } + } + + /** + * Inheritable privileges apply to the checked entity and its child entities, if applied to the + * entity's ACL or any of its parents' ACLs. + */ + @PolarisImmutable + interface InheritablePrivilege extends IndividualPrivilege { + @Override + @Value.Parameter + String name(); + + static IndividualPrivilege inheritablePrivilege(String name) { + return ImmutableInheritablePrivilege.of(name); + } + } + + /** + * Non-inheritable privileges apply only to the checked entity if those are present in the + * entity's ACL. Non-inheritable privileges that are present on an entity's parent are ignored + * during access checks. + */ + @PolarisImmutable + interface NonInheritablePrivilege extends IndividualPrivilege { + @Override + @Value.Parameter + String name(); + + static NonInheritablePrivilege nonInheritablePrivilege(String name) { + return ImmutableNonInheritablePrivilege.of(name); + } + } + + /** + * A composite privilege represents a group of {@linkplain IndividualPrivilege individual + * privileges}. + * + *

Access checks for a composite privilege only succeed if all individual + * privileges match. + * + * @see AlternativePrivilege + */ + @PolarisImmutable + interface CompositePrivilege extends Privilege { + @Value.Parameter(order = 1) + @Override + String name(); + + @Override + @Value.Parameter(order = 2) + Set resolved(); + + @Value.Check + default void check() { + checkArgument(!resolved().isEmpty(), "Must have at least one individual privilege"); + } + + static CompositePrivilege compositePrivilege( + String name, Iterable privileges) { + return ImmutableCompositePrivilege.of(name, privileges); + } + + static CompositePrivilege compositePrivilege(String name, IndividualPrivilege... privileges) { + return ImmutableCompositePrivilege.of(name, Set.of(privileges)); + } + } + + /** + * An "alternative privilege" represents a group of {@linkplain IndividualPrivilege individual + * privileges}. + * + *

Access checks for a alternative privilege succeed if any individual + * privileges of the alternative privilege matches. + * + * @see CompositePrivilege + */ + @PolarisImmutable + interface AlternativePrivilege extends Privilege { + @Value.Parameter(order = 1) + @Override + String name(); + + @Override + @Value.Parameter(order = 2) + Set resolved(); + + @Override + default boolean mustMatchAll() { + return false; + } + + @Value.Check + default void check() { + checkArgument(!resolved().isEmpty(), "Must have at least one individual privilege"); + } + + static AlternativePrivilege alternativePrivilege( + String name, Iterable privileges) { + return ImmutableAlternativePrivilege.of(name, privileges); + } + + static AlternativePrivilege alternativePrivilege( + String name, IndividualPrivilege... privileges) { + return ImmutableAlternativePrivilege.of(name, Set.of(privileges)); + } + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java new file mode 100644 index 0000000000..81719b2c2f --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeCheck.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import jakarta.annotation.Nonnull; + +public interface PrivilegeCheck { + /** + * Retrieve the effective privileges, which is the set of all granted privileges minus the set of + * all restricted privileges, for the given ACL and all its {@linkplain AclChain#parent() parent + * ACLs}. + * + *

The set of granted privileges contains all privileges that are {@linkplain + * AclEntry#granted() granted} to any of the role IDs for this {@linkplain PrivilegeCheck + * privilege check instance}. A privilege is granted if it is granted to any role in the given ACL + * or any of its parents. See note on non-inheritable privileges below. + * + *

The set of restricted privileges contains all privileges that are {@linkplain + * AclEntry#restricted() restricted} for any of the role IDs for this {@linkplain PrivilegeCheck + * privilege check instance}. A privilege is restricted if it is restricted to any role in the + * given ACL or any of its parents. See note on non-inheritable privileges below. + * + *

{@linkplain Privilege.NonInheritablePrivilege Non-inheritable} privileges are only effective + * on the "top" (first) ACL of the given {@linkplain AclChain ACL chain}, but are ignored on any + * of the parents. For example, a non-inheritable privilege {@code NON_INHERIT} that is + * granted on the entity's parent, will not be returned as an effective + * privilege. Similarly, non-inheritable privileges that are restricted on a parent, are + * not "subtracted" from the set of effective privileges. + * + *

A privilege is effective if it is granted and not restricted. + * + * @param aclChain Represents the chain of ACLs to check. The ACL for the entity must be the first + * one in the chain. + */ + PrivilegeSet effectivePrivilegeSet(@Nonnull AclChain aclChain); +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java new file mode 100644 index 0000000000..38f42762f3 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSet.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import static org.apache.polaris.persistence.nosql.authz.api.Constants.EMPTY_PRIVILEGE_SET; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import jakarta.annotation.Nonnull; +import java.util.Collection; +import java.util.Iterator; +import java.util.Set; + +/** + * Represents a set of individual privileges. + * + *

External representations, for example, when serialized as JSON, prefer composite privileges + * over individual ones. This means that if a {@linkplain PrivilegeSet privilege set} contains all + * privileges included in a composite privilege, only the name of the composite privilege is + * serialized. If multiple composite privileges match, all matching ones are serialized. + * + *

This {@linkplain Set set} of {@linkplain Privilege privileges} natively represents only + * individual privileges. To "collapse" those into composite privileges, use {@link + * #collapseComposites(Privileges)}. + * + *

Composite privileges can however be used as arguments to all {@code contains()} functions and + * to the {@code add()}/{@code remove()} builder methods. + * + *

Do not use a {@link PrivilegeSet} when the special meaning of composite privileges + * needs to be retained, especially during access checks. + */ +public interface PrivilegeSet extends Set { + + static PrivilegeSet emptyPrivilegeSet() { + return EMPTY_PRIVILEGE_SET; + } + + boolean contains(Privilege privilege); + + Iterator iterator(Privileges privileges); + + @Override + boolean isEmpty(); + + byte[] toByteArray(); + + default Set collapseComposites(Privileges privileges) { + return privileges.collapseComposites(this); + } + + /** + * Checks whether the given {@link Privilege} is fully contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + @Override + boolean contains(Object o); + + /** + * Checks whether all of given {@link Privilege privileges} is contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + @Override + boolean containsAll(@Nonnull Collection c); + + /** + * Checks whether any of given {@link Privilege privileges} is contained in this privilege set. + * + *

For {@linkplain Privilege.CompositePrivilege composite privileges}, returns {@code true} if + * and only if all the individual privileges of a composite privilege are contained in this set. + */ + boolean containsAny(Iterable privilege); + + interface PrivilegeSetBuilder { + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull Iterable privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull PrivilegeSet privilegeSet); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivileges(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder addPrivilege(@Nonnull Privilege privilege); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull Iterable privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull PrivilegeSet privilegeSet); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivileges(@Nonnull Privilege... privileges); + + @CanIgnoreReturnValue + PrivilegeSetBuilder removePrivilege(@Nonnull Privilege privilege); + + PrivilegeSet build(); + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java new file mode 100644 index 0000000000..d1b54ee83e --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/PrivilegeSetJsonFilter.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +final class PrivilegeSetJsonFilter { + @Override + public boolean equals(Object obj) { + return obj instanceof PrivilegeSet privilegeSet && privilegeSet.isEmpty(); + } + + @Override + public int hashCode() { + // never used + return 1; + } +} diff --git a/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java new file mode 100644 index 0000000000..a90c1d84ea --- /dev/null +++ b/persistence/nosql/authz/api/src/main/java/org/apache/polaris/persistence/nosql/authz/api/Privileges.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.api; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Collection; +import java.util.Set; + +/** + * Container holding all defined {@linkplain Privilege privileges}. + * + *

Implementation is provided as an {@link ApplicationScoped @ApplicationScoped} bean. + */ +public interface Privileges { + /** + * Return the {@linkplain Privilege privilege} for the given ID. + * + * @throws IllegalArgumentException if no privilege for the given ID exists. + */ + Privilege byId(int id); + + /** + * Return the {@linkplain Privilege privilege} for the given name (case-sensitive). + * + * @throws IllegalArgumentException if no privilege for the given name exists. + */ + Privilege byName(@Nonnull String name); + + int idForName(@Nonnull String name); + + int idForPrivilege(@Nonnull Privilege privilege); + + PrivilegeSet nonInheritablePrivileges(); + + /** + * Returns the set of {@linkplain Privilege privilege} from the given {@linkplain PrivilegeSet + * privilege set}, replacing all {@linkplain Privilege.IndividualPrivilege individual privileges} + * that fully match the {@linkplain Privilege.CompositePrivilege composite privileges}. If + * multiple composite privileges match, all of those will be returned. + */ + Set collapseComposites(@Nonnull PrivilegeSet value); + + /** Informative function, returns all known {@linkplain Privilege privileges}. */ + Collection all(); + + /** Informative function, the IDs provided all known {@linkplain Privilege privileges}. */ + Set allIds(); + + /** Informative function, returns the names of all known {@linkplain Privilege privileges}. */ + Set allNames(); + + PrivilegeSet.PrivilegeSetBuilder newPrivilegesSetBuilder(); + + Acl.AclBuilder newAclBuilder(); + + AclEntry.AclEntryBuilder newAclEntryBuilder(); + + PrivilegeCheck startPrivilegeCheck(boolean anonymous, Set roleIds); +} diff --git a/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/api/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/impl/build.gradle.kts b/persistence/nosql/authz/impl/build.gradle.kts new file mode 100644 index 0000000000..fdc91d972b --- /dev/null +++ b/persistence/nosql/authz/impl/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ implementation" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-authz-spi")) + api(project(":polaris-version")) + + implementation(libs.agrona) + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + testFixturesApi(libs.jakarta.annotation.api) + testFixturesApi(libs.jakarta.validation.api) + testFixturesApi(libs.jakarta.inject.api) + testFixturesApi(libs.jakarta.enterprise.cdi.api) + + testFixturesImplementation(project(":polaris-persistence-nosql-authz-api")) + testFixturesImplementation(project(":polaris-persistence-nosql-authz-spi")) + + testImplementation(libs.weld.se.core) + testImplementation(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testFixturesCompileOnly(platform(libs.jackson.bom)) + testFixturesCompileOnly("com.fasterxml.jackson.core:jackson-databind") +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java new file mode 100644 index 0000000000..6bef3cb976 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclDeserializer.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; + +class AclDeserializer extends JsonDeserializer { + @Override + public Acl deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (p.currentToken() != JsonToken.START_OBJECT) { + throw new JsonMappingException(p, "Unexpected token " + p.currentToken()); + } + + var privileges = JacksonPrivilegesModule.currentPrivileges(); + var builder = AclImpl.builder(privileges); + for (var t = p.nextToken(); t != JsonToken.END_OBJECT; t = p.nextToken()) { + if (t == JsonToken.FIELD_NAME) { + var roleId = p.currentName(); + p.nextToken(); + var entry = p.readValueAs(AclEntry.class); + builder.addEntry(roleId, entry); + } + } + return builder.build(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java new file mode 100644 index 0000000000..727ccc5c78 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclEntryBuilderImpl.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Collection; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.ImmutableAclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +final class AclEntryBuilderImpl implements AclEntry.AclEntryBuilder { + private final PrivilegeSet.PrivilegeSetBuilder granted; + private final PrivilegeSet.PrivilegeSetBuilder restricted; + + AclEntryBuilderImpl(Privileges privileges) { + this.granted = privileges.newPrivilegesSetBuilder(); + this.restricted = privileges.newPrivilegesSetBuilder(); + } + + AclEntryBuilderImpl(Privileges privileges, AclEntry aclEntry) { + this.granted = privileges.newPrivilegesSetBuilder().addPrivileges(aclEntry.granted()); + this.restricted = privileges.newPrivilegesSetBuilder().addPrivileges(aclEntry.restricted()); + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Privilege privilege) { + this.granted.addPrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Privilege... privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull Collection privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder grant(@Nonnull PrivilegeSet privileges) { + this.granted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Privilege privilege) { + this.granted.removePrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Privilege... privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull Collection privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder revoke(@Nonnull PrivilegeSet privileges) { + this.granted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Privilege privilege) { + this.restricted.addPrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Privilege... privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull Collection privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder restrict(@Nonnull PrivilegeSet privileges) { + this.restricted.addPrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Privilege privilege) { + this.restricted.removePrivilege(privilege); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Privilege... privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull Collection privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry.AclEntryBuilder unrestrict(@Nonnull PrivilegeSet privileges) { + this.restricted.removePrivileges(privileges); + return this; + } + + @Override + public AclEntry build() { + return ImmutableAclEntry.of(this.granted.build(), this.restricted.build()); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java new file mode 100644 index 0000000000..85359ede3f --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclImpl.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.agrona.collections.ObjectHashSet.DEFAULT_INITIAL_CAPACITY; + +import jakarta.annotation.Nonnull; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.agrona.collections.Hashing; +import org.agrona.collections.Object2ObjectHashMap; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +record AclImpl(Object2ObjectHashMap map) implements Acl { + + AclImpl(AclBuilderImpl map) { + this(new Object2ObjectHashMap<>(map.map)); + } + + @Override + public void forEach(@Nonnull BiConsumer consumer) { + map.forEach(consumer); + } + + @Override + public void entriesForRoleIds( + @Nonnull Set roleIds, @Nonnull Consumer aclEntryConsumer) { + roleIds.stream().map(map::get).filter(Objects::nonNull).forEach(aclEntryConsumer); + } + + static AclBuilder builder(Privileges privileges) { + return new AclBuilderImpl(privileges); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + AclImpl acl = (AclImpl) o; + return map.equals(acl.map); + } + + @Override + @Nonnull + public String toString() { + return "Acl{" + + map.entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining(",")) + + "}"; + } + + private static final class AclBuilderImpl implements AclBuilder { + + private final Object2ObjectHashMap map; + private final Privileges privileges; + + private AclBuilderImpl(Privileges privileges) { + this.privileges = privileges; + this.map = + new Object2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + } + + @Override + public AclBuilder from(@Nonnull Acl instance) { + map.clear(); + map.putAll(((AclImpl) instance).map); + return this; + } + + @Override + public AclBuilder addEntry(@Nonnull String roleId, @Nonnull AclEntry entry) { + map.put(roleId, entry); + return this; + } + + @Override + public AclBuilder removeEntry(@Nonnull String roleId) { + map.remove(roleId); + return this; + } + + @Override + public AclBuilder modify( + @Nonnull String roleId, @Nonnull Consumer entry) { + map.compute( + roleId, + (k, e) -> { + AclEntry.AclEntryBuilder builder = + e != null + ? new AclEntryBuilderImpl(privileges, e) + : new AclEntryBuilderImpl(privileges); + entry.accept(builder); + AclEntry updated = builder.build(); + return updated.isEmpty() ? null : updated; + }); + return this; + } + + @Override + public Acl build() { + return new AclImpl(this); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java new file mode 100644 index 0000000000..7033ce5b50 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/AclSerializer.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.Acl; + +class AclSerializer extends JsonSerializer { + @Override + public void serialize(Acl value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + value.forEach( + (role, entry) -> { + try { + gen.writeObjectField(role, entry); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + gen.writeEndObject(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java new file mode 100644 index 0000000000..b5beb49ac0 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/JacksonPrivilegesModule.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.spi.CDI; +import java.util.function.Function; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +public class JacksonPrivilegesModule extends SimpleModule { + public JacksonPrivilegesModule() { + addDeserializer(PrivilegeSet.class, new PrivilegeSetDeserializer()); + addSerializer(PrivilegeSet.class, new PrivilegeSetSerializer()); + addDeserializer(Acl.class, new AclDeserializer()); + addSerializer(Acl.class, new AclSerializer()); + } + + static Privileges currentPrivileges() { + return cdiResolve(Privileges.class); + } + + // TODO the following is the same as in AbstractTypeIdResolver + + /** + * Resolve the given type via {@link CDI#current() CDI.current()}. For tests the resolution via + * CDI can be {@linkplain CDIResolver#setResolver(Function) routed to a custom function}. + */ + private static R cdiResolve(Class type) { + // TODO instead of doing the 'CDIResolver' dance, we could (should?) have an attribute in the + // `DatabindContext` holding a reference to the CDI instance (referred to as `Instance`). + var resolved = CDIResolver.resolver.apply(type); + @SuppressWarnings("unchecked") + var r = (R) resolved; + return r; + } + + public static final class CDIResolver { + static Function, ?> resolver = CDIResolver::resolveViaCurrentCDI; + + /** + * The helper function {@link #cdiResolve(Class)} is used by {@link JacksonPrivilegesModule} + * implementations to resolve the {@link Privileges} instance, and the default implementation of + * {@link #cdiResolve(Class)} relies on {@link CDI#current() CDI.current()} to resolve against a + * "singleton" {@link CDI} instance. Some tests do not use CDI. Setting a custom resolver + * function helps in such scenarios. + */ + @SuppressWarnings("unused") + public static void setResolver(Function, ?> resolver) { + CDIResolver.resolver = resolver; + } + + /** + * Manually reset a custom {@linkplain #setResolver(Function) CDI resolver}. This is usually + * performed automatically after each test case. + */ + @SuppressWarnings("unused") + public static void resetResolver() { + resolver = CDIResolver::resolveViaCurrentCDI; + } + + private static Object resolveViaCurrentCDI(Class type) { + Instance selected = CDI.current().select(type); + checkArgument(selected.isResolvable(), "Cannot resolve %s", type); + checkArgument(!selected.isAmbiguous(), "Ambiguous type %s", type); + return selected.get(); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java new file mode 100644 index 0000000000..ecda12f50c --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeCheckImpl.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Set; +import org.apache.polaris.persistence.nosql.authz.api.AclChain; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +record PrivilegeCheckImpl(Set roleIds, Privileges privileges) implements PrivilegeCheck { + + @Override + public PrivilegeSet effectivePrivilegeSet(@Nonnull AclChain aclChain) { + // Collect granted+restricted from the direct ACL + PrivilegeSet.PrivilegeSetBuilder topGranted = privileges.newPrivilegesSetBuilder(); + PrivilegeSet.PrivilegeSetBuilder topRestricted = privileges.newPrivilegesSetBuilder(); + aclChain + .acl() + .entriesForRoleIds( + roleIds, + aclEntry -> { + topGranted.addPrivileges(aclEntry.granted()); + topRestricted.addPrivileges(aclEntry.restricted()); + }); + + // Collect granted+restricted from the parent ACLs + PrivilegeSet.PrivilegeSetBuilder granted = privileges.newPrivilegesSetBuilder(); + PrivilegeSet.PrivilegeSetBuilder restricted = privileges.newPrivilegesSetBuilder(); + while (aclChain.parent().isPresent()) { + aclChain = aclChain.parent().get(); + aclChain + .acl() + .entriesForRoleIds( + roleIds, + aclEntry -> { + granted.addPrivileges(aclEntry.granted()); + restricted.addPrivileges(aclEntry.restricted()); + }); + } + + // Remove non-inheritable privileges from the ACLs of the parents. Since those are not + // inheritable, they do not apply. + PrivilegeSet nonInheritable = privileges.nonInheritablePrivileges(); + granted.removePrivileges(nonInheritable); + restricted.removePrivileges(nonInheritable); + + // Add all privileges from the "direct" ACL, this includes the non-inheritable privileges + granted.addPrivileges(topGranted.build()); + restricted.addPrivileges(topRestricted.build()); + + // Remove restricted privileges from the granted privileges, `granted` now contains the + // effective privileges + granted.removePrivileges(restricted.build()); + + return granted.build(); + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java new file mode 100644 index 0000000000..a41635ddaf --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetDeserializer.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule.currentPrivileges; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; + +class PrivilegeSetDeserializer extends JsonDeserializer { + @Override + public PrivilegeSet deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + switch (p.currentToken()) { + case VALUE_NULL: + return new PrivilegeSetImpl(currentPrivileges(), new byte[0]); + case VALUE_STRING: + // Internal, storage serialization format. + var bytes = p.getBinaryValue(); + return new PrivilegeSetImpl(currentPrivileges(), bytes); + case START_ARRAY: + // External/REST serialization format using privilege names. + var privileges = currentPrivileges(); + var builder = PrivilegeSetImpl.builder(privileges); + for (var t = p.nextToken(); ; t = p.nextToken()) { + // Note: switch(t) lets checkstyle fail + if (t == JsonToken.VALUE_STRING) { + builder.addPrivilege(privileges.byName(p.getText())); + } + if (t == JsonToken.END_ARRAY) { + break; + } + } + return builder.build(); + default: + throw new JsonMappingException(p, "Unexpected JSON token " + p.currentToken()); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java new file mode 100644 index 0000000000..bbcdf09f95 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetImpl.java @@ -0,0 +1,368 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import com.google.common.collect.AbstractIterator; +import jakarta.annotation.Nonnull; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Collection; +import java.util.Iterator; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; + +/** + * Represents a set of {@link Privilege}s, implemented with a bit-map. + * + *

Also provides JSON serializer that is capable of serializing using the privileges in a + * space-efficient binary format (the bit-map, if the current JSON view is {@code StorageView}), and + * a verbose textual representation (for "external" serialization). + */ +record PrivilegeSetImpl(Privileges privileges, byte[] bytes) implements PrivilegeSet { + private PrivilegeSetImpl(Privileges privileges, PrivilegeSetBuilderImpl builder) { + this(privileges, builder.bitSet.toByteArray()); + } + + @Override + public int size() { + var size = 0; + for (var b : bytes) { + var i = b & 0xFF; + size += Integer.bitCount(i); + } + return size; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public byte[] toByteArray() { + return Arrays.copyOf(bytes, bytes.length); + } + + @SuppressWarnings("NullableProblems") + @Override + public Object[] toArray() { + return toArray(new Privilege[0]); + } + + @SuppressWarnings("NullableProblems") + @Override + public T[] toArray(T[] a) { + var size = size(); + var arrType = requireNonNull(a).getClass().getComponentType(); + checkArgument(arrType.isAssignableFrom(Privilege.class)); + var arr = (Object[]) a; + if (arr.length < size) { + arr = Arrays.copyOf(a, size); + } + + var i = 0; + for (Privilege privilege : this) { + arr[i++] = privilege; + } + + @SuppressWarnings("unchecked") + var r = (T[]) arr; + return r; + } + + @Override + public boolean add(Privilege privilege) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean addAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean retainAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("NullableProblems") + @Override + public boolean removeAll(Collection c) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(Object privilege) { + return privilege instanceof Privilege p && contains(p); + } + + @Override + public boolean contains(Privilege privilege) { + return privilege.mustMatchAll() + ? containsMustMatchAll(privilege) + : containsMustMatchAny(privilege); + } + + private boolean containsMustMatchAll(Privilege privilege) { + var arr = this.bytes; + for (Privilege.IndividualPrivilege p : privilege.resolved()) { + var id = privileges.idForName(p.name()); + var index = byteIndex(id); + if (arr.length <= index) { + return false; + } + var v = arr[index]; + var mask = mask(id); + if ((v & mask) != mask) { + return false; + } + } + return true; + } + + private boolean containsMustMatchAny(Privilege privilege) { + var arr = this.bytes; + for (var p : privilege.resolved()) { + var id = privileges.idForName(p.name()); + var index = byteIndex(id); + if (arr.length <= index) { + continue; + } + var v = arr[index]; + var mask = mask(id); + if ((v & mask) == mask) { + return true; + } + } + return false; + } + + @Override + @SuppressWarnings("PatternMatchingInstanceof") + public boolean containsAll(@Nonnull Collection privileges) { + for (var o : privileges) { + if (!(o instanceof Privilege)) { + return false; + } + if (!contains((Privilege) o)) { + return false; + } + } + return true; + } + + @Override + public boolean containsAny(Iterable privileges) { + for (var o : privileges) { + if (contains(o)) { + return true; + } + } + return false; + } + + @SuppressWarnings("NullableProblems") + @Override + public Iterator iterator() { + return iterator(privileges); + } + + @Override + public Iterator iterator(Privileges privileges) { + return new AbstractIterator<>() { + private int idx = 0; + + @Override + protected Privilege computeNext() { + while (true) { + var i = idx++; + var arrIdx = byteIndex(i); + if (arrIdx >= PrivilegeSetImpl.this.bytes.length) { + return endOfData(); + } + var mask = mask(i); + var v = PrivilegeSetImpl.this.bytes[arrIdx]; + if ((v & mask) == mask) { + return privileges.byId(i); + } + } + } + }; + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PrivilegeSetImpl privilegeSet = (PrivilegeSetImpl) o; + return Arrays.equals(privilegeSet.bytes, bytes); + } + + @Nonnull + @Override + public String toString() { + var id = 0; + var sb = new StringBuilder("PrivilegeSet{"); + var first = true; + for (var b : this.bytes) { + for (int i = 0, m = 1; i < 8; i++, m <<= 1) { + if ((b & m) != 0) { + if (first) { + first = false; + } else { + sb.append(','); + } + sb.append(id); + } + id++; + } + } + return sb.append('}').toString(); + } + + static int byteIndex(int id) { + return id >> 3; + } + + static byte mask(int id) { + return (byte) (1 << (id & 7)); + } + + static PrivilegeSetBuilder builder(Privileges privileges) { + return new PrivilegeSetBuilderImpl(privileges); + } + + byte[] bytesUnsafe() { + return bytes; + } + + static final class PrivilegeSetBuilderImpl implements PrivilegeSetBuilder { + private final BitSet bitSet; + private final Privileges privileges; + + private PrivilegeSetBuilderImpl(Privileges privileges) { + this.bitSet = new BitSet(); + this.privileges = privileges; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull Iterable privileges) { + for (var privilege : privileges) { + addPrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull Privilege... privileges) { + for (var privilege : privileges) { + addPrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder addPrivileges(@Nonnull PrivilegeSet privilegeSet) { + var bytes = + (privilegeSet instanceof PrivilegeSetImpl privilegeSetImpl) + ? privilegeSetImpl.bytes + : privilegeSet.toByteArray(); + // TODO `valueOf(byte[])` is way more expensive than `valueOf(long[])` + bitSet.or(BitSet.valueOf(bytes)); + return this; + } + + @Override + public PrivilegeSetBuilder addPrivilege(@Nonnull Privilege privilege) { + for (var individualPrivilege : privilege.resolved()) { + var id = privileges.idForName(individualPrivilege.name()); + this.bitSet.set(id); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull Iterable privileges) { + for (var privilege : privileges) { + removePrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull Privilege... privileges) { + for (var privilege : privileges) { + removePrivilege(privilege); + } + return this; + } + + @Override + public PrivilegeSetBuilder removePrivileges(@Nonnull PrivilegeSet privilegeSet) { + var bytes = + (privilegeSet instanceof PrivilegeSetImpl privilegeSetImpl) + ? privilegeSetImpl.bytes + : privilegeSet.toByteArray(); + // TODO `valueOf(byte[])` is way more expensive than `valueOf(long[])` + bitSet.andNot(BitSet.valueOf(bytes)); + return this; + } + + @Override + public PrivilegeSetBuilder removePrivilege(@Nonnull Privilege privilege) { + for (var individualPrivilege : privilege.resolved()) { + var id = privileges.idForName(individualPrivilege.name()); + this.bitSet.clear(id); + } + return this; + } + + @Override + public PrivilegeSet build() { + return new PrivilegeSetImpl(privileges, this); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java new file mode 100644 index 0000000000..1e7d5b65aa --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegeSetSerializer.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule.currentPrivileges; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; + +public class PrivilegeSetSerializer extends JsonSerializer { + @Override + public void serialize(PrivilegeSet value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + var view = serializers.getActiveView(); + if (view != null && view.getSimpleName().equals("StorageView")) { + // When serializing for/to persistence as a use the bit-encoded/binary + // serialization. This is triggered when the current Jackson view is + // `org.apache.polaris.persistence.nosql.api.obj.Obj.StorageView`. + if (!value.isEmpty()) { + var impl = (PrivilegeSetImpl) value; + gen.writeBinary(impl.bytesUnsafe()); + } else { + gen.writeNull(); + } + } else { + // Otherwise, for external/REST, use the privilege names from the "global" set of + // privileges. + gen.writeStartArray(); + var collapsed = value.collapseComposites(currentPrivileges()); + for (var privilege : collapsed) { + gen.writeString(privilege.name()); + } + gen.writeEndArray(); + } + } +} diff --git a/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java new file mode 100644 index 0000000000..b05d23fba1 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesImpl.java @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static java.util.Collections.unmodifiableSet; +import static java.util.Objects.requireNonNull; +import static org.agrona.collections.ObjectHashSet.DEFAULT_INITIAL_CAPACITY; +import static org.apache.polaris.persistence.nosql.authz.api.PredefinedRoles.ANONYMOUS_ROLE; +import static org.apache.polaris.persistence.nosql.authz.api.PredefinedRoles.PUBLIC_ROLE; + +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.agrona.collections.Hashing; +import org.agrona.collections.Int2ObjectHashMap; +import org.agrona.collections.Object2ObjectHashMap; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.authz.spi.ImmutablePrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; +import org.immutables.value.Value; + +@ApplicationScoped +class PrivilegesImpl implements Privileges { + private final Object2ObjectHashMap nameToPrivilege; + private final Int2ObjectHashMap idToPrivilege; + private final PrivilegeSet nonInheritablePrivileges; + private final Privilege.CompositePrivilege[] compositePrivileges; + private final Collection allPrivileges; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PrivilegesImpl( + Instance privilegesProviders, PrivilegesRepository privilegesRepository) { + this(privilegesProviders.stream(), privilegesRepository); + } + + @VisibleForTesting + PrivilegesImpl(Stream stream, PrivilegesRepository privilegesRepository) { + var providedPrivileges = + new HashMap>(); + for (var providerIter = stream.iterator(); providerIter.hasNext(); ) { + var privilegesProvider = providerIter.next(); + for (var definitions = privilegesProvider.privilegeDefinitions().iterator(); + definitions.hasNext(); ) { + var definition = definitions.next(); + var duplicate = + providedPrivileges.putIfAbsent( + definition.privilege().name(), Map.entry(definition, privilegesProvider)); + if (duplicate != null) { + throw new IllegalStateException( + format( + "Duplicate privilege definition for name '%s'", definition.privilege().name())); + } + } + } + + var individualPrivileges = + providedPrivileges.values().stream() + .map(Map.Entry::getKey) + .map(PrivilegeDefinition::privilege) + .filter(Privilege.IndividualPrivilege.class::isInstance) + .toList(); + var individualPrivilegeNames = + individualPrivileges.stream().map(Privilege::name).collect(Collectors.toSet()); + + while (true) { + var mapping = privilegesRepository.fetchPrivilegesMapping(); + var mapped = mapping.nameToId(); + var maxId = mapped.values().stream().max(Integer::compareTo).orElse(-1); + + if (!mapped.keySet().containsAll(individualPrivilegeNames)) { + // not all individual privileges have an integer ID - need to persist an updated version of + // the privilege name-to-id mapping! + + var existingNames = mapped.keySet(); + var namesToMap = new HashSet<>(individualPrivilegeNames); + namesToMap.removeAll(existingNames); + + var newMappingBuilder = ImmutablePrivilegesMapping.builder(); + newMappingBuilder.putAllNameToId(mapped); + for (var nameToMap : namesToMap) { + newMappingBuilder.putNameToId(nameToMap, ++maxId); + } + + var newMapping = newMappingBuilder.build(); + if (privilegesRepository.updatePrivilegesMapping(mapping, newMapping)) { + // our update worked, go ahead + mapped = newMapping.nameToId(); + } else { + // oops, a race with a concurrently starting instance, retry... + continue; + } + } + + // At this point we know that all individual privileges have a valid and persisted + // constant integer ID + this.nameToPrivilege = + new Object2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + this.idToPrivilege = + new Int2ObjectHashMap<>(DEFAULT_INITIAL_CAPACITY, Hashing.DEFAULT_LOAD_FACTOR, false); + var nonInheritablePrivilegesBuilder = PrivilegeSetImpl.builder(this); + var compositePrivilegesBuilder = new ArrayList(); + for (var provided : providedPrivileges.values()) { + var privilege = provided.getKey().privilege(); + var name = privilege.name(); + var id = (int) requireNonNull(mapped.getOrDefault(name, -1)); + this.nameToPrivilege.put(name, ImmutablePrivilegeAndId.of(privilege, id)); + if (id != -1) { + this.idToPrivilege.put(id, ImmutablePrivilegeAndId.of(privilege, id)); + } + if (privilege instanceof Privilege.NonInheritablePrivilege nonInheritablePrivilege) { + nonInheritablePrivilegesBuilder.addPrivilege(nonInheritablePrivilege); + } else if (privilege instanceof Privilege.CompositePrivilege compositePrivilege) { + compositePrivilegesBuilder.add(compositePrivilege); + } + } + this.nonInheritablePrivileges = nonInheritablePrivilegesBuilder.build(); + this.compositePrivileges = + compositePrivilegesBuilder.toArray(Privilege.CompositePrivilege[]::new); + this.allPrivileges = + nameToPrivilege.values().stream().map(PrivilegeAndId::privilege).toList(); + + break; + } + } + + @PolarisImmutable + interface PrivilegeAndId { + @Value.Parameter + Privilege privilege(); + + @Value.Parameter + int id(); + } + + @Override + public Privilege byName(@Nonnull String name) { + var ex = nameToPrivilege.get(name); + checkArgument(ex != null, "Unknown privilege '%s'", name); + return ex.privilege(); + } + + @Override + public Privilege byId(int id) { + var ex = idToPrivilege.get(id); + checkArgument(ex != null, "Unknown privilege ID %s", id); + return ex.privilege(); + } + + @Override + public int idForName(@Nonnull String name) { + var ex = nameToPrivilege.get(name); + checkArgument(ex != null && ex.id() >= 0, "Unknown individual privilege '%s'", name); + return ex.id(); + } + + @Override + public int idForPrivilege(@Nonnull Privilege privilege) { + return idForName(privilege.name()); + } + + @Override + public Set allNames() { + return unmodifiableSet(nameToPrivilege.keySet()); + } + + @Override + public Set allIds() { + return unmodifiableSet(idToPrivilege.keySet()); + } + + @Override + public PrivilegeSet nonInheritablePrivileges() { + return nonInheritablePrivileges; + } + + @Override + public Set collapseComposites(@Nonnull PrivilegeSet value) { + Set collapsed = new HashSet<>(); + + var work = newPrivilegesSetBuilder().addPrivileges(value); + for (Privilege.CompositePrivilege compositePrivilege : compositePrivileges) { + if (value.contains(compositePrivilege)) { + work.removePrivileges(compositePrivilege); + collapsed.add(compositePrivilege); + } + } + + collapsed.addAll(work.build()); + + return collapsed; + } + + @Override + public Collection all() { + return allPrivileges; + } + + @Override + public PrivilegeSet.PrivilegeSetBuilder newPrivilegesSetBuilder() { + return PrivilegeSetImpl.builder(this); + } + + @Override + public AclEntry.AclEntryBuilder newAclEntryBuilder() { + return new AclEntryBuilderImpl(this); + } + + @Override + public Acl.AclBuilder newAclBuilder() { + return AclImpl.builder(this); + } + + @Override + public PrivilegeCheck startPrivilegeCheck(boolean anonymous, Set roleIds) { + Set effectiveRoles = new HashSet<>(roleIds); + effectiveRoles.add(anonymous ? ANONYMOUS_ROLE : PUBLIC_ROLE); + return new PrivilegeCheckImpl(effectiveRoles, this); + } +} diff --git a/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 0000000000..4225dfcfee --- /dev/null +++ b/persistence/nosql/authz/impl/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1,20 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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. +# + +org.apache.polaris.persistence.nosql.authz.impl.JacksonPrivilegesModule diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java new file mode 100644 index 0000000000..cae209157e --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestProvider.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.AlternativePrivilege.alternativePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.CompositePrivilege.compositePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.InheritablePrivilege.inheritablePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.NonInheritablePrivilege.nonInheritablePrivilege; + +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; + +class PrivilegesTestProvider implements PrivilegesProvider { + @Override + public String name() { + return "TEST-ONLY Privileges"; + } + + @Override + public Stream privilegeDefinitions() { + var zero = inheritablePrivilege("zero"); + var one = inheritablePrivilege("one"); + var two = inheritablePrivilege("two"); + var three = inheritablePrivilege("three"); + var four = inheritablePrivilege("four"); + var five = inheritablePrivilege("five"); + var six = inheritablePrivilege("six"); + var seven = inheritablePrivilege("seven"); + var eight = inheritablePrivilege("eight"); + var nine = inheritablePrivilege("nine"); + var nonInherit = nonInheritablePrivilege("nonInherit"); + var oneTwoThree = compositePrivilege("oneTwoThree", one, two, three); + var duplicateOneTwoThree = compositePrivilege("duplicateOneTwoThree", one, two, three); + var twoThreeFour = compositePrivilege("twoThreeFour", two, three, four); + var fiveSix = compositePrivilege("fiveSix", five, six); + var zeroTwo = alternativePrivilege("zeroTwo", zero, two); + var eightNine = alternativePrivilege("eightNine", eight, nine); + var individuals = + Stream.of( + zero, + one, + two, + three, + four, + five, + six, + seven, + eight, + nine, + nonInherit, + oneTwoThree, + duplicateOneTwoThree, + twoThreeFour, + fiveSix, + zeroTwo, + eightNine); + + var manyFoo = + IntStream.range(0, 128) + .mapToObj(id -> Privilege.InheritablePrivilege.inheritablePrivilege("foo_" + id)); + + return Stream.concat(individuals, manyFoo) + .map(p -> PrivilegeDefinition.builder().privilege(p).build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java new file mode 100644 index 0000000000..fbed9e8b34 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/PrivilegesTestRepository.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import jakarta.annotation.Nonnull; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; + +class PrivilegesTestRepository implements PrivilegesRepository { + private final AtomicReference current = + new AtomicReference<>(PrivilegesMapping.builder().build()); + + @Override + public boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState) { + var v = current.updateAndGet(curr -> curr.equals(expectedState) ? newState : curr); + return v.equals(newState); + } + + @Override + @Nonnull + public PrivilegesMapping fetchPrivilegesMapping() { + return Optional.ofNullable(current.get()).orElse(PrivilegesMapping.EMPTY); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java new file mode 100644 index 0000000000..0fec66b362 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclEntryImpl.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAclEntryImpl { + @InjectSoftAssertions SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @ParameterizedTest + @MethodSource + public void aclEntry(AclEntry aclEntry) throws Exception { + + String json = mapper.writeValueAsString(aclEntry); + soft.assertThat(mapper.readValue(json, AclEntry.class)).isEqualTo(aclEntry); + } + + static Stream aclEntry() { + Privilege zero = privileges.byId(0); + Privilege eight = privileges.byId(8); + Privilege nine = privileges.byId(9); + return Stream.of( + privileges.newAclEntryBuilder().build(), + privileges.newAclEntryBuilder().grant(zero).build(), + privileges.newAclEntryBuilder().restrict(zero).build(), + privileges.newAclEntryBuilder().grant(zero).restrict(zero).build(), + privileges.newAclEntryBuilder().grant(zero, eight, nine).build(), + privileges.newAclEntryBuilder().restrict(zero, eight, nine).build(), + privileges + .newAclEntryBuilder() + .grant(zero, eight, nine) + .restrict(zero, eight, nine) + .build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java new file mode 100644 index 0000000000..552141279e --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestAclImpl.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Acl; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestAclImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @ParameterizedTest + @MethodSource + public void acl(Acl acl) throws Exception { + String json = mapper.writeValueAsString(acl); + soft.assertThat(mapper.readValue(json, Acl.class)).isEqualTo(acl); + } + + static Stream acl() { + return Stream.of( + privileges.newAclBuilder().build(), + privileges.newAclBuilder().addEntry("one", privileges.newAclEntryBuilder().build()).build(), + privileges + .newAclBuilder() + .addEntry("one", privileges.newAclEntryBuilder().build()) + .addEntry("two", privileges.newAclEntryBuilder().build()) + .addEntry("three", privileges.newAclEntryBuilder().build()) + .build(), + privileges + .newAclBuilder() + .addEntry("oneTwoThree", privileges.newAclEntryBuilder().build()) + .build(), + privileges + .newAclBuilder() + .addEntry( + "one", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .addEntry( + "two", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .addEntry( + "three", + privileges + .newAclEntryBuilder() + .grant(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .restrict(Privilege.InheritablePrivilege.inheritablePrivilege("zero")) + .build()) + .build()); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java new file mode 100644 index 0000000000..47084d0593 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeCheckImpl.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.AclChain; +import org.apache.polaris.persistence.nosql.authz.api.AclEntry; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeCheck; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegeCheckImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static PrivilegesImpl privileges; + + @BeforeAll + static void setUp() { + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + static PrivilegeSet privilegeSet(Privilege... values) { + return privileges.newPrivilegesSetBuilder().addPrivileges(values).build(); + } + + static AclEntry aclEntry(PrivilegeSet granted, PrivilegeSet restricted) { + return privileges.newAclEntryBuilder().grant(granted).restrict(restricted).build(); + } + + @Test + public void restricted() { + Privilege zero = privileges.byName("zero"); + Privilege two = privileges.byName("two"); + Privilege four = privileges.byName("four"); + Privilege nine = privileges.byName("nine"); + Privilege zeroTwo = privileges.byName("zeroTwo"); + Privilege eightNine = privileges.byName("eightNine"); + + AclChain root = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "root-g0-r0", + privileges.newAclEntryBuilder().grant(zero).restrict(zero).build()) + .addEntry("root-g0", privileges.newAclEntryBuilder().grant(zero).build()) + .addEntry("root-r0", privileges.newAclEntryBuilder().restrict(zero).build()) + .addEntry("root-r4", privileges.newAclEntryBuilder().restrict(four).build()) + .addEntry("x-0", privileges.newAclEntryBuilder().grant(zero).build()) + .build(), + Optional.empty()); + AclChain parent = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "parent-g2-r2", + privileges.newAclEntryBuilder().grant(two).restrict(two).grant().build()) + .addEntry("parent-g2", privileges.newAclEntryBuilder().grant(two).grant().build()) + .addEntry("parent-r2", privileges.newAclEntryBuilder().restrict(two).build()) + .addEntry("parent-r0", privileges.newAclEntryBuilder().restrict(zero).build()) + .addEntry("x-0", privileges.newAclEntryBuilder().restrict(zero).build()) + .build(), + Optional.of(root)); + AclChain leaf = + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "leaf-g4-r4", + privileges.newAclEntryBuilder().grant(four).restrict(four).build()) + .addEntry("leaf-g4", privileges.newAclEntryBuilder().grant(four).build()) + .build(), + Optional.of(parent)); + + PrivilegeCheck privilegeCheck = + privileges.startPrivilegeCheck(false, Collections.singleton("root-g0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isTrue(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(nine)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zeroTwo)).isTrue(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(eightNine)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("root-g0", "root-r0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("root-g0-r0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("leaf-g4")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(four)).isTrue(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("leaf-g4", "root-r4")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(four)).isFalse(); + + privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("x-0")); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(leaf).contains(zero)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(parent).contains(zero)).isFalse(); + soft.assertThat(privilegeCheck.effectivePrivilegeSet(root).contains(zero)).isTrue(); + } + + @ParameterizedTest + @MethodSource + public void nonInheritablePrivilegeOnTop(AclChain aclChain, PrivilegeSet expectedEffective) { + PrivilegeCheck privilegeCheck = privileges.startPrivilegeCheck(false, Set.of("user")); + + PrivilegeSet effective = privilegeCheck.effectivePrivilegeSet(aclChain); + soft.assertThat(effective).isEqualTo(expectedEffective); + } + + static Stream nonInheritablePrivilegeOnTop() { + Privilege one = privileges.byName("one"); + Privilege two = privileges.byName("two"); + Privilege three = privileges.byName("three"); + Privilege nonInherit = privileges.byName("nonInherit"); + + return Stream.of( + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one, nonInherit), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(two), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(three), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three, nonInherit)), + // + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "user", aclEntry(privilegeSet(two, nonInherit), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(three), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three)), + // + arguments( + // top + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(one), privilegeSet())) + .build(), + // mid + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry("user", aclEntry(privilegeSet(two), privilegeSet())) + .build(), + // root + Optional.of( + AclChain.aclChain( + privileges + .newAclBuilder() + .addEntry( + "user", + aclEntry(privilegeSet(three, nonInherit), privilegeSet())) + .build(), + Optional.empty()))))), + privilegeSet(one, two, three)) + // + ); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java new file mode 100644 index 0000000000..2832973f39 --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegeSetImpl.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static java.util.Collections.singleton; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import java.util.ArrayList; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.PrivilegeSet; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegeSetImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + private static ObjectMapper mapper; + private static PrivilegesImpl privileges; + + // Needed for tests, don't want to pull in polaris-persistence-nosql-api just for this test + static final class StorageView {} + + @BeforeAll + static void setUp() { + mapper = new ObjectMapper().findAndRegisterModules(); + privileges = + new PrivilegesImpl(Stream.of(new PrivilegesTestProvider()), new PrivilegesTestRepository()); + JacksonPrivilegesModule.CDIResolver.setResolver(x -> privileges); + } + + @SuppressWarnings("RedundantCollectionOperation") + @ParameterizedTest + @MethodSource + public void singlePrivileges(Privilege.IndividualPrivilege privilege) throws Exception { + var privilegeSet = privileges.newPrivilegesSetBuilder().addPrivilege(privilege).build(); + soft.assertThat(privilegeSet.isEmpty()).isFalse(); + soft.assertThat(privilegeSet.contains(privilege)).isTrue(); + soft.assertThat(privilegeSet.containsAll(singleton(privilege))).isTrue(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .build() + .contains(privilege)) + .isTrue(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .contains(privilege)) + .isFalse(); + + var index = privileges.idForPrivilege(privilege) >> 3; + soft.assertThat(privilegeSet.toByteArray()).hasSize(index + 1); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .toByteArray()) + .hasSize(0); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .removePrivilege(privilege) + .build() + .isEmpty()) + .isTrue(); + + var writer = mapper.writerWithView(StorageView.class); + var json = writer.writeValueAsString(privilegeSet); + soft.assertThat(mapper.readValue(json, PrivilegeSet.class)).isEqualTo(privilegeSet); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isFalse(); + + for (var j = 0; j < 256; j++) { + if (j == privileges.idForPrivilege(privilege)) { + continue; + } + var other = Privilege.InheritablePrivilege.inheritablePrivilege("zero"); + soft.assertThat(privilegeSet.contains(other)).isFalse(); + soft.assertThat(privilegeSet.containsAll(singleton(other))).isFalse(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .build() + .contains(other)) + .isFalse(); + soft.assertThat( + privileges + .newPrivilegesSetBuilder() + .addPrivileges(privilegeSet) + .addPrivilege(other) + .build() + .contains(other)) + .isTrue(); + } + } + + static Stream singlePrivileges() { + return IntStream.range(0, 128) + .mapToObj(id -> Privilege.InheritablePrivilege.inheritablePrivilege("foo_" + id)); + } + + @ParameterizedTest + @MethodSource + public void nameSerialization(PrivilegeSet privilegeSet) throws Exception { + var json = mapper.writeValueAsString(privilegeSet); + + var deserialized = mapper.readValue(json, PrivilegeSet.class); + soft.assertThat(deserialized).isEqualTo(privilegeSet); + soft.assertThat(deserialized).containsExactlyInAnyOrderElementsOf(privilegeSet); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isTrue(); + } + + static Stream nameSerialization() { + return Stream.concat( + privileges.all().stream() + .map(p -> privileges.newPrivilegesSetBuilder().addPrivilege(p).build()), + Stream.of(privileges.newPrivilegesSetBuilder().addPrivileges(privileges.all()).build())); + } + + @ParameterizedTest + @MethodSource + public void compositeByNameSerialization( + Privilege composite, Set more, Set inJson) throws Exception { + var privilegeSet = + privileges.newPrivilegesSetBuilder().addPrivilege(composite).addPrivileges(more).build(); + + var json = mapper.writeValueAsString(privilegeSet); + + var deserialized = mapper.readValue(json, PrivilegeSet.class); + soft.assertThat(deserialized).isEqualTo(privilegeSet); + soft.assertThat(deserialized).containsAll(composite.resolved()); + soft.assertThat(deserialized.containsAll(composite.resolved())).isTrue(); + soft.assertThat(deserialized.containsAll(more)).isTrue(); + + var jsonNode = mapper.readValue(json, JsonNode.class); + soft.assertThat(jsonNode.isArray()).isTrue(); + + var values = new ArrayList(); + var arrayNode = (ArrayNode) jsonNode; + for (var i = 0; i < arrayNode.size(); i++) { + values.add(arrayNode.get(i).asText()); + } + + soft.assertThat(values) + .containsExactlyInAnyOrderElementsOf( + inJson.stream().map(Privilege::name).collect(Collectors.toList())); + } + + static Stream compositeByNameSerialization() { + var oneTwoThree = privileges.byName("oneTwoThree"); + var duplicateOneTwoThree = privileges.byName("duplicateOneTwoThree"); + var twoThreeFour = privileges.byName("twoThreeFour"); + var fiveSix = privileges.byName("fiveSix"); + var three = privileges.byName("three"); + var five = privileges.byName("five"); + var seven = privileges.byName("seven"); + + return Stream.of( + arguments(oneTwoThree, Set.of(), Set.of(oneTwoThree, duplicateOneTwoThree)), + arguments( + oneTwoThree, + Set.of(three, five, seven), + Set.of(oneTwoThree, duplicateOneTwoThree, five, seven)), + arguments(duplicateOneTwoThree, Set.of(), Set.of(oneTwoThree, duplicateOneTwoThree)), + arguments(twoThreeFour, Set.of(), Set.of(twoThreeFour)), + arguments(twoThreeFour, Set.of(three, five, seven), Set.of(twoThreeFour, five, seven)), + arguments(fiveSix, Set.of(), Set.of(fiveSix)), + arguments(fiveSix, Set.of(three, five, seven), Set.of(fiveSix, three, seven)), + arguments( + twoThreeFour, + Set.of(oneTwoThree), + Set.of(oneTwoThree, duplicateOneTwoThree, twoThreeFour))); + } +} diff --git a/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java new file mode 100644 index 0000000000..df9b8a388b --- /dev/null +++ b/persistence/nosql/authz/impl/src/test/java/org/apache/polaris/persistence/nosql/authz/impl/TestPrivilegesImpl.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.impl; + +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.AlternativePrivilege.alternativePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.CompositePrivilege.compositePrivilege; +import static org.apache.polaris.persistence.nosql.authz.api.Privilege.InheritablePrivilege.inheritablePrivilege; + +import java.util.Set; +import java.util.stream.Stream; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; +import org.apache.polaris.persistence.nosql.authz.api.Privileges; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegeDefinition; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesProvider; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestPrivilegesImpl { + @SuppressWarnings("VisibilityModifier") + @InjectSoftAssertions + protected SoftAssertions soft; + + @Test + public void duplicateNames() { + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + testPrivileges( + testPrivilegesProvider( + "PROVIDER1", inheritablePrivilege("foo"), inheritablePrivilege("foo")))) + .withMessage("Duplicate privilege definition for name 'foo'"); + + soft.assertThatIllegalStateException() + .isThrownBy( + () -> + testPrivileges( + testPrivilegesProvider("PROVIDER1", inheritablePrivilege("foo")), + testPrivilegesProvider("PROVIDER2", inheritablePrivilege("foo")))) + .withMessage("Duplicate privilege definition for name 'foo'"); + } + + static PrivilegesProvider testPrivilegesProvider(String name, Privilege... privileges) { + return new PrivilegesProvider() { + @Override + public Stream privilegeDefinitions() { + return Stream.of(privileges).map(p -> PrivilegeDefinition.builder().privilege(p).build()); + } + + @Override + public String name() { + return name; + } + }; + } + + static Privileges testPrivileges(PrivilegesProvider... privilegeProviders) { + var privilegesRepository = new PrivilegesTestRepository(); + return new PrivilegesImpl(Stream.of(privilegeProviders), privilegesRepository); + } + + @Test + public void compositeAndAlternativePrivileges() { + var foo = inheritablePrivilege("foo"); + var bar = inheritablePrivilege("bar"); + var baz = inheritablePrivilege("baz"); + var fooBarBaz = compositePrivilege("foo-bar-baz", foo, bar, baz); + var alt1 = alternativePrivilege("alt1", foo, bar); + + var meow = inheritablePrivilege("meow"); + var woof = inheritablePrivilege("woof"); + var meowWoof = compositePrivilege("meow-woof", meow, woof); + var alt2 = alternativePrivilege("alt2", meow, woof); + + var privileges = + testPrivileges( + testPrivilegesProvider("PROVIDER", foo, bar, baz, fooBarBaz, meow, woof, meowWoof)); + + var privilegeSet = privileges.newPrivilegesSetBuilder().addPrivilege(fooBarBaz).build(); + soft.assertThat(privileges.newPrivilegesSetBuilder().addPrivileges(foo, bar, baz).build()) + .isEqualTo(privilegeSet); + soft.assertThat(privilegeSet) + .isEqualTo(privileges.newPrivilegesSetBuilder().addPrivileges(foo, bar, baz).build()); + soft.assertThat(privilegeSet.contains(fooBarBaz)).isTrue(); + soft.assertThat(privilegeSet.contains(alt1)).isTrue(); + soft.assertThat(privilegeSet.contains(foo)).isTrue(); + soft.assertThat(privilegeSet.contains(bar)).isTrue(); + soft.assertThat(privilegeSet.contains(baz)).isTrue(); + soft.assertThat(privilegeSet.contains(meowWoof)).isFalse(); + soft.assertThat(privilegeSet.contains(meow)).isFalse(); + soft.assertThat(privilegeSet.contains(woof)).isFalse(); + + var privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(foo, bar, baz); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(woof, meow)); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(meowWoof)); + + privilegeSet = privileges.newPrivilegesSetBuilder().addPrivileges(foo, baz).build(); + soft.assertThat(privilegeSet.contains(alt1)).isTrue(); + + privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(foo, baz); + + privilegeSet = privileges.newPrivilegesSetBuilder().addPrivileges(meowWoof).build(); + soft.assertThat(privileges.newPrivilegesSetBuilder().addPrivileges(meow, woof).build()) + .isEqualTo(privilegeSet); + soft.assertThat(privilegeSet) + .isEqualTo(privileges.newPrivilegesSetBuilder().addPrivileges(meowWoof).build()); + soft.assertThat(privilegeSet.contains(fooBarBaz)).isFalse(); + soft.assertThat(privilegeSet.contains(alt1)).isFalse(); + soft.assertThat(privilegeSet.contains(foo)).isFalse(); + soft.assertThat(privilegeSet.contains(bar)).isFalse(); + soft.assertThat(privilegeSet.contains(baz)).isFalse(); + soft.assertThat(privilegeSet.contains(meowWoof)).isTrue(); + soft.assertThat(privilegeSet.contains(meow)).isTrue(); + soft.assertThat(privilegeSet.contains(woof)).isTrue(); + soft.assertThat(privilegeSet.contains(alt2)).isTrue(); + + privilegeSetList = Lists.newArrayList(privilegeSet.iterator(privileges)); + soft.assertThat(privilegeSetList).containsExactlyInAnyOrder(woof, meow); + soft.assertThat(privilegeSetList).doesNotContainAnyElementsOf(Set.of(foo, bar, baz)); + } +} diff --git a/persistence/nosql/authz/spi/build.gradle.kts b/persistence/nosql/authz/spi/build.gradle.kts new file mode 100644 index 0000000000..616ef82729 --- /dev/null +++ b/persistence/nosql/authz/spi/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ SPI" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + api(project(":polaris-version")) + + implementation(libs.guava) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java new file mode 100644 index 0000000000..ee38b96eaa --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegeDefinition.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.spi; + +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * Wrapper holding one provided privilege, intended to be potentially extended with, for example, a + * human-readable description. + */ +@PolarisImmutable +public interface PrivilegeDefinition { + Privilege privilege(); + + static ImmutablePrivilegeDefinition.Builder builder() { + return ImmutablePrivilegeDefinition.builder(); + } +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java new file mode 100644 index 0000000000..178f48eed9 --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesMapping.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.spi; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.Map; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * Value type holding the Polaris system-wide mapping of {@linkplain Privilege privilege} + * {@linkplain Privilege#name() names} to and from integer IDs. + */ +@PolarisImmutable +@JsonSerialize(as = ImmutablePrivilegesMapping.class) +@JsonDeserialize(as = ImmutablePrivilegesMapping.class) +public interface PrivilegesMapping { + Map nameToId(); + + static ImmutablePrivilegesMapping.Builder builder() { + return ImmutablePrivilegesMapping.builder(); + } + + PrivilegesMapping EMPTY = PrivilegesMapping.builder().build(); +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java new file mode 100644 index 0000000000..4614402bd6 --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesProvider.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.spi; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.stream.Stream; + +/** + * Implementations implemented as {@link ApplicationScoped @ApplicationScoped} beans, define the + * privileges that are available to Polaris. + */ +public interface PrivilegesProvider { + /** Human-readable name. */ + String name(); + + Stream privilegeDefinitions(); +} diff --git a/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java new file mode 100644 index 0000000000..de44c3790e --- /dev/null +++ b/persistence/nosql/authz/spi/src/main/java/org/apache/polaris/persistence/nosql/authz/spi/PrivilegesRepository.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.spi; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.authz.api.Privilege; + +/** + * API to maintain the Polaris system-wide mapping of {@linkplain Privilege privilege} {@linkplain + * Privilege#name() names} to and from integer IDs. + * + *

Implementation is provided as an {@link ApplicationScoped @ApplicationScoped} bean. + */ +public interface PrivilegesRepository { + @Nonnull + PrivilegesMapping fetchPrivilegesMapping(); + + boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState); +} diff --git a/persistence/nosql/authz/store-nosql/build.gradle.kts b/persistence/nosql/authz/store-nosql/build.gradle.kts new file mode 100644 index 0000000000..59077c911a --- /dev/null +++ b/persistence/nosql/authz/store-nosql/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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. + */ + +plugins { + id("org.kordamp.gradle.jandex") + id("polaris-server") +} + +description = "Polaris AuthZ NoSQL persistence" + +dependencies { + implementation(project(":polaris-persistence-nosql-authz-api")) + implementation(project(":polaris-persistence-nosql-authz-spi")) + implementation(project(":polaris-persistence-nosql-api")) + implementation(project(":polaris-persistence-nosql-maintenance-api")) + implementation(project(":polaris-persistence-nosql-maintenance-spi")) + + implementation(libs.guava) + implementation(libs.slf4j.api) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + + compileOnly(project(":polaris-immutables")) + annotationProcessor(project(":polaris-immutables", configuration = "processor")) + + compileOnly(libs.jakarta.annotation.api) + compileOnly(libs.jakarta.validation.api) + compileOnly(libs.jakarta.inject.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + + testFixturesApi(libs.weld.se.core) + testFixturesApi(libs.weld.junit5) + testRuntimeOnly(libs.smallrye.jandex) + + testFixturesRuntimeOnly(project(":polaris-persistence-nosql-cdi-weld")) + testFixturesApi(testFixtures(project(":polaris-persistence-nosql-cdi-weld"))) + + testImplementation(testFixtures(project(":polaris-persistence-nosql-inmemory"))) + testImplementation(libs.threeten.extra) + + testCompileOnly(libs.jakarta.annotation.api) + testCompileOnly(libs.jakarta.validation.api) + testCompileOnly(libs.jakarta.inject.api) + testCompileOnly(libs.jakarta.enterprise.cdi.api) +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java new file mode 100644 index 0000000000..ac5841b6d0 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesMappingObj.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.store.nosql; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.polaris.immutables.PolarisImmutable; +import org.apache.polaris.persistence.nosql.api.obj.AbstractObjType; +import org.apache.polaris.persistence.nosql.api.obj.Obj; +import org.apache.polaris.persistence.nosql.api.obj.ObjType; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; + +@PolarisImmutable +@JsonSerialize(as = ImmutablePrivilegesMappingObj.class) +@JsonDeserialize(as = ImmutablePrivilegesMappingObj.class) +public interface PrivilegesMappingObj extends Obj { + + String PRIVILEGES_MAPPING_REF_NAME = "privileges-mapping"; + + ObjType TYPE = new PrivilegesMappingObjType(); + + @Override + default ObjType type() { + return TYPE; + } + + PrivilegesMapping privilegesMapping(); + + static ImmutablePrivilegesMappingObj.Builder builder() { + return ImmutablePrivilegesMappingObj.builder(); + } + + final class PrivilegesMappingObjType extends AbstractObjType { + public PrivilegesMappingObjType() { + super("privileges-mapping", "Privileges Mapping", PrivilegesMappingObj.class); + } + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java new file mode 100644 index 0000000000..b50657690a --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRepositoryImpl.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.store.nosql; + +import static org.apache.polaris.persistence.nosql.api.obj.ObjRef.objRef; +import static org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj.PRIVILEGES_MAPPING_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import org.apache.polaris.persistence.nosql.api.Persistence; +import org.apache.polaris.persistence.nosql.api.SystemPersistence; +import org.apache.polaris.persistence.nosql.api.obj.ObjRef; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; + +@ApplicationScoped +class PrivilegesRepositoryImpl implements PrivilegesRepository { + private final Persistence persistence; + private ObjRef privilegesMappingObjRef; + + @SuppressWarnings("CdiInjectionPointsInspection") + @Inject + PrivilegesRepositoryImpl(@SystemPersistence Persistence persistence) { + this.persistence = persistence; + } + + @PostConstruct + void init() { + privilegesMappingObjRef = + persistence + .fetchOrCreateReference( + PRIVILEGES_MAPPING_REF_NAME, + () -> Optional.of(objRef(PrivilegesMappingObj.TYPE, persistence.generateId()))) + .pointer() + .orElseThrow(); + } + + @Override + public boolean updatePrivilegesMapping( + @Nonnull PrivilegesMapping expectedState, @Nonnull PrivilegesMapping newState) { + var existing = + Optional.ofNullable(persistence.fetch(privilegesMappingObjRef, PrivilegesMappingObj.class)); + + if (!existing + .map(PrivilegesMappingObj::privilegesMapping) + .orElse(PrivilegesMapping.EMPTY) + .equals(expectedState)) { + return false; + } + if (expectedState.equals(newState)) { + return true; + } + + var newObj = + PrivilegesMappingObj.builder() + .id(privilegesMappingObjRef.id()) + .versionToken("" + persistence.generateId()) + .privilegesMapping(newState) + .build(); + + return existing + .map( + privilegesMappingObj -> + persistence.conditionalUpdate( + privilegesMappingObj, newObj, PrivilegesMappingObj.class) + != null) + .orElseGet(() -> persistence.conditionalInsert(newObj, PrivilegesMappingObj.class) != null); + } + + @Override + @Nonnull + public PrivilegesMapping fetchPrivilegesMapping() { + return Optional.ofNullable( + persistence.fetch(privilegesMappingObjRef, PrivilegesMappingObj.class)) + .map(PrivilegesMappingObj::privilegesMapping) + .orElse(PrivilegesMapping.EMPTY); + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java new file mode 100644 index 0000000000..1290172d1c --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/java/org/apache/polaris/persistence/nosql/authz/store/nosql/PrivilegesRetainedIdentifier.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.store.nosql; + +import static org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj.PRIVILEGES_MAPPING_REF_NAME; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.persistence.nosql.api.exceptions.ReferenceNotFoundException; +import org.apache.polaris.persistence.nosql.maintenance.spi.PerRealmRetainedIdentifier; +import org.apache.polaris.persistence.nosql.maintenance.spi.RetainedCollector; + +@ApplicationScoped +class PrivilegesRetainedIdentifier implements PerRealmRetainedIdentifier { + + @Override + public String name() { + return "Privileges Mapping"; + } + + @Override + public boolean identifyRetained(@Nonnull RetainedCollector collector) { + if (!collector.isSystemRealm()) { + return false; + } + + try { + // This retains both the reference _and_ the referenced object. + collector + .realmPersistence() + .fetchReferenceHead(PRIVILEGES_MAPPING_REF_NAME, PrivilegesMappingObj.class); + } catch (ReferenceNotFoundException ignored) { + } + + // Intentionally return false, let the maintenance service's identifier decide + return false; + } +} diff --git a/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a297f1aa53 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/beans.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType new file mode 100644 index 0000000000..b5fb88869f --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/main/resources/META-INF/services/org.apache.polaris.persistence.nosql.api.obj.ObjType @@ -0,0 +1,20 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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. +# + +org.apache.polaris.persistence.nosql.authz.store.nosql.PrivilegesMappingObj$PrivilegesMappingObjType diff --git a/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java b/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java new file mode 100644 index 0000000000..4b682d78ca --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/java/org/apache/polaris/persistence/nosql/authz/store/nosql/TestPrivilegesRepositoryImpl.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.polaris.persistence.nosql.authz.store.nosql; + +import static org.assertj.core.api.InstanceOfAssertFactories.map; + +import jakarta.inject.Inject; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesMapping; +import org.apache.polaris.persistence.nosql.authz.spi.PrivilegesRepository; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@EnableWeld +public class TestPrivilegesRepositoryImpl { + @InjectSoftAssertions SoftAssertions soft; + @WeldSetup WeldInitiator weld = WeldInitiator.performDefaultDiscovery(); + + @Inject PrivilegesRepository repository; + + @Test + public void privilegesRepository() { + soft.assertThat(repository.fetchPrivilegesMapping()) + .extracting(PrivilegesMapping::nameToId, map(String.class, Integer.class)) + .isEmpty(); + + soft.assertThat( + repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, PrivilegesMapping.EMPTY)) + .isTrue(); + var initial = PrivilegesMapping.builder().putNameToId("one", 1).putNameToId("two", 2).build(); + soft.assertThat(repository.updatePrivilegesMapping(initial, PrivilegesMapping.EMPTY)).isFalse(); + soft.assertThat(repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, initial)).isTrue(); + soft.assertThat(repository.updatePrivilegesMapping(PrivilegesMapping.EMPTY, initial)).isFalse(); + + var update = PrivilegesMapping.builder().from(initial).putNameToId("three", 3).build(); + soft.assertThat(repository.updatePrivilegesMapping(update, initial)).isFalse(); + soft.assertThat(repository.updatePrivilegesMapping(initial, update)).isTrue(); + soft.assertThat(repository.updatePrivilegesMapping(update, update)).isTrue(); + } +} diff --git a/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml b/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..aafa701dc4 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties b/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties new file mode 100644 index 0000000000..c26169e0e1 --- /dev/null +++ b/persistence/nosql/authz/store-nosql/src/test/resources/weld.properties @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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. +# + +# See https://bugs.openjdk.org/browse/JDK-8349545 +org.jboss.weld.bootstrap.concurrentDeployment=false