diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/EntraIdSyncSourceInfo.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/EntraIdSyncSourceInfo.java new file mode 100644 index 00000000000..33cd25e0d61 --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/EntraIdSyncSourceInfo.java @@ -0,0 +1,135 @@ +/* + * 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.ranger.ugsyncutil.model; + +public class EntraIdSyncSourceInfo { + private String tenantId; + private String graphBaseUrl; + private String authMode; + private String membershipMode; + private String incrementalSync; + private String groupFilter; + + private long totalUsersSynced; + private long totalGroupsSynced; + private long totalUsersDeleted; + private long totalGroupsDeleted; + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getGraphBaseUrl() { + return graphBaseUrl; + } + + public void setGraphBaseUrl(String graphBaseUrl) { + this.graphBaseUrl = graphBaseUrl; + } + + public String getAuthMode() { + return authMode; + } + + public void setAuthMode(String authMode) { + this.authMode = authMode; + } + + public String getMembershipMode() { + return membershipMode; + } + + public void setMembershipMode(String membershipMode) { + this.membershipMode = membershipMode; + } + + public String getIncrementalSync() { + return incrementalSync; + } + + public void setIncrementalSync(String incrementalSync) { + this.incrementalSync = incrementalSync; + } + + public String getGroupFilter() { + return groupFilter; + } + + public void setGroupFilter(String groupFilter) { + this.groupFilter = groupFilter; + } + + public long getTotalUsersSynced() { + return totalUsersSynced; + } + + public void setTotalUsersSynced(long totalUsersSynced) { + this.totalUsersSynced = totalUsersSynced; + } + + public long getTotalGroupsSynced() { + return totalGroupsSynced; + } + + public void setTotalGroupsSynced(long totalGroupsSynced) { + this.totalGroupsSynced = totalGroupsSynced; + } + + public long getTotalUsersDeleted() { + return totalUsersDeleted; + } + + public void setTotalUsersDeleted(long totalUsersDeleted) { + this.totalUsersDeleted = totalUsersDeleted; + } + + public long getTotalGroupsDeleted() { + return totalGroupsDeleted; + } + + public void setTotalGroupsDeleted(long totalGroupsDeleted) { + this.totalGroupsDeleted = totalGroupsDeleted; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + toString(sb); + return sb.toString(); + } + + public StringBuilder toString(StringBuilder sb) { + sb.append("EntraIdSyncSourceInfo [tenantId= ").append(tenantId); + sb.append(", graphBaseUrl= ").append(graphBaseUrl); + sb.append(", authMode= ").append(authMode); + sb.append(", membershipMode= ").append(membershipMode); + sb.append(", incrementalSync= ").append(incrementalSync); + sb.append(", groupFilter= ").append(groupFilter); + sb.append(", totalUsersSynced= ").append(totalUsersSynced); + sb.append(", totalGroupsSynced= ").append(totalGroupsSynced); + sb.append(", totalUsersDeleted= ").append(totalUsersDeleted); + sb.append(", totalGroupsDeleted= ").append(totalGroupsDeleted); + sb.append("]"); + return sb; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/UgsyncAuditInfo.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/UgsyncAuditInfo.java index 5858a0213a3..84f5766b271 100644 --- a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/UgsyncAuditInfo.java +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/UgsyncAuditInfo.java @@ -30,6 +30,7 @@ public class UgsyncAuditInfo { private LdapSyncSourceInfo ldapSyncSourceInfo; private UnixSyncSourceInfo unixSyncSourceInfo; private FileSyncSourceInfo fileSyncSourceInfo; + private EntraIdSyncSourceInfo entraIdSyncSourceInfo; public Long getNoOfNewUsers() { return noOfNewUsers; @@ -95,6 +96,14 @@ public void setFileSyncSourceInfo(FileSyncSourceInfo fileSyncSourceInfo) { this.fileSyncSourceInfo = fileSyncSourceInfo; } + public EntraIdSyncSourceInfo getEntraIdSyncSourceInfo() { + return entraIdSyncSourceInfo; + } + + public void setEntraIdSyncSourceInfo(EntraIdSyncSourceInfo entraIdSyncSourceInfo) { + this.entraIdSyncSourceInfo = entraIdSyncSourceInfo; + } + public String getUserName() { return userName; } @@ -127,6 +136,7 @@ public StringBuilder toString(StringBuilder sb) { sb.append(", ldapSyncSourceInfo= ").append(ldapSyncSourceInfo); sb.append(", unixSyncSourceInfo= ").append(unixSyncSourceInfo); sb.append(", fileSyncSourceInfo= ").append(fileSyncSourceInfo); + sb.append(", entraIdSyncSourceInfo= ").append(entraIdSyncSourceInfo); sb.append("]"); return sb; } diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaEntry.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaEntry.java new file mode 100644 index 00000000000..87fadc0156f --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaEntry.java @@ -0,0 +1,69 @@ +/* + * 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.ranger.ugsyncutil.model.graph; + +import java.util.Objects; + +public final class DeltaEntry { + private final T value; + private final boolean removed; + + public DeltaEntry(T value, boolean removed) { + this.value = value; + this.removed = removed; + } + + public static DeltaEntry upsert(T value) { + return new DeltaEntry<>(value, false); + } + + public static DeltaEntry removed(T value) { + return new DeltaEntry<>(value, true); + } + + public T getValue() { + return value; + } + + public boolean isRemoved() { + return removed; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DeltaEntry that = (DeltaEntry) o; + return removed == that.removed && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value, removed); + } + + @Override + public String toString() { + return "DeltaEntry{removed=" + removed + ", value=" + value + '}'; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaPage.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaPage.java new file mode 100644 index 00000000000..a9748fa12e5 --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/DeltaPage.java @@ -0,0 +1,70 @@ +/* + * 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.ranger.ugsyncutil.model.graph; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class DeltaPage { + private final List> entries; + private final String deltaLink; + private final boolean resynced; + + public DeltaPage(List> entries, String deltaLink) { + this(entries, deltaLink, false); + } + + public DeltaPage(List> entries, String deltaLink, boolean resynced) { + this.entries = entries == null ? Collections.>emptyList() : Collections.unmodifiableList(new ArrayList<>(entries)); + this.deltaLink = deltaLink; + this.resynced = resynced; + } + + public List> getEntries() { + return entries; + } + + public String getDeltaLink() { + return deltaLink; + } + + /** + * True when this page was produced by a forced full resync after the previous delta + * link expired or was invalidated. The caller should treat such a cycle as a + * reconciliation (snapshot-diff delete computation), because a resync does not replay + * {@code @removed} tombstones for objects deleted during the gap. + */ + public boolean isResynced() { + return resynced; + } + + public int size() { + return entries.size(); + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + @Override + public String toString() { + return "DeltaPage{entries=" + entries.size() + ", deltaLink=" + (deltaLink == null ? "null" : "present") + '}'; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphGroup.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphGroup.java new file mode 100644 index 00000000000..ea3b6feb9de --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphGroup.java @@ -0,0 +1,124 @@ +/* + * 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.ranger.ugsyncutil.model.graph; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class GraphGroup { + private String id; + private String displayName; + private String mailNickname; + private boolean securityEnabled = true; + private final Map additionalAttributes = new LinkedHashMap<>(); + private final List memberChanges = new ArrayList<>(); + + public GraphGroup() { + } + + public GraphGroup(String id, String displayName) { + this.id = id; + this.displayName = displayName; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getMailNickname() { + return mailNickname; + } + + public void setMailNickname(String mailNickname) { + this.mailNickname = mailNickname; + } + + public boolean isSecurityEnabled() { + return securityEnabled; + } + + public void setSecurityEnabled(boolean securityEnabled) { + this.securityEnabled = securityEnabled; + } + + public Map getAdditionalAttributes() { + return Collections.unmodifiableMap(additionalAttributes); + } + + public void putAdditionalAttribute(String key, String value) { + if (key != null) { + additionalAttributes.put(key, value); + } + } + + public List getMemberChanges() { + return Collections.unmodifiableList(memberChanges); + } + + public void addMemberChange(GraphMemberRef ref) { + if (ref != null) { + memberChanges.add(ref); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GraphGroup that = (GraphGroup) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "GraphGroup{" + + "id=" + id + + ", displayName=" + displayName + + ", mailNickname=" + mailNickname + + ", securityEnabled=" + securityEnabled + + ", additionalAttributes=" + additionalAttributes + + ", memberChanges=" + memberChanges.size() + + '}'; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphMemberRef.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphMemberRef.java new file mode 100644 index 00000000000..61914d66a1e --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphMemberRef.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.ranger.ugsyncutil.model.graph; + +import java.util.Objects; + +public final class GraphMemberRef { + private final String id; + private final MemberType type; + private final boolean removed; + + public GraphMemberRef(String id, MemberType type, boolean removed) { + this.id = id; + this.type = type == null ? MemberType.OTHER : type; + this.removed = removed; + } + + public static GraphMemberRef added(String id, MemberType type) { + return new GraphMemberRef(id, type, false); + } + + public static GraphMemberRef removed(String id, MemberType type) { + return new GraphMemberRef(id, type, true); + } + + public String getId() { + return id; + } + + public MemberType getType() { + return type; + } + + public boolean isRemoved() { + return removed; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GraphMemberRef that = (GraphMemberRef) o; + return removed == that.removed && Objects.equals(id, that.id) && type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(id, type, removed); + } + + @Override + public String toString() { + return "GraphMemberRef{id=" + id + ", type=" + type + ", removed=" + removed + '}'; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphUser.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphUser.java new file mode 100644 index 00000000000..3b3ec81a515 --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GraphUser.java @@ -0,0 +1,122 @@ +/* + * 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.ranger.ugsyncutil.model.graph; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class GraphUser { + private String id; + private String userPrincipalName; + private String mail; + private String displayName; + private boolean accountEnabled = true; + + private final Map additionalAttributes = new LinkedHashMap<>(); + + public GraphUser() { + } + + public GraphUser(String id, String userPrincipalName, String mail, String displayName) { + this.id = id; + this.userPrincipalName = userPrincipalName; + this.mail = mail; + this.displayName = displayName; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserPrincipalName() { + return userPrincipalName; + } + + public void setUserPrincipalName(String userPrincipalName) { + this.userPrincipalName = userPrincipalName; + } + + public String getMail() { + return mail; + } + + public void setMail(String mail) { + this.mail = mail; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public boolean isAccountEnabled() { + return accountEnabled; + } + + public void setAccountEnabled(boolean accountEnabled) { + this.accountEnabled = accountEnabled; + } + + public Map getAdditionalAttributes() { + return Collections.unmodifiableMap(additionalAttributes); + } + + public void putAdditionalAttribute(String key, String value) { + if (key != null) { + additionalAttributes.put(key, value); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GraphUser that = (GraphUser) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "GraphUser{id=" + id + + ", userPrincipalName=" + userPrincipalName + + ", mail=" + mail + + ", displayName=" + displayName + + ", accountEnabled=" + accountEnabled + + ", additionalAttributes=" + additionalAttributes + + '}'; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GroupMembershipPage.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GroupMembershipPage.java new file mode 100644 index 00000000000..aad12331b99 --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/GroupMembershipPage.java @@ -0,0 +1,61 @@ +/* + * 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.ranger.ugsyncutil.model.graph; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class GroupMembershipPage { + private final List> groups; + private final Map> membersByGroupId; + private final String deltaLink; + private final boolean resynced; + + public GroupMembershipPage(List> groups, Map> membersByGroupId, String deltaLink, boolean resynced) { + this.groups = (groups == null) ? Collections.emptyList() : groups; + this.membersByGroupId = (membersByGroupId == null) ? new HashMap<>() : membersByGroupId; + this.deltaLink = deltaLink; + this.resynced = resynced; + } + + public List> getGroups() { + return groups; + } + + public Map> getMembersByGroupId() { + return membersByGroupId; + } + + public Set getMembers(String groupId) { + Set m = membersByGroupId.get(groupId); + return (m == null) ? new LinkedHashSet<>() : m; + } + + public String getDeltaLink() { + return deltaLink; + } + + public boolean isResynced() { + return resynced; + } +} diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MemberType.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MemberType.java new file mode 100644 index 00000000000..c61aa860464 --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MemberType.java @@ -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. + */ +package org.apache.ranger.ugsyncutil.model.graph; + +public enum MemberType { USER, GROUP, OTHER } diff --git a/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MembershipMode.java b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MembershipMode.java new file mode 100644 index 00000000000..916c6159a6e --- /dev/null +++ b/ugsync-util/src/main/java/org/apache/ranger/ugsyncutil/model/graph/MembershipMode.java @@ -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. + */ + +package org.apache.ranger.ugsyncutil.model.graph; + +public enum MembershipMode { DIRECT, TRANSITIVE } diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/EntraIdUserGroupSource.java b/ugsync/src/main/java/org/apache/ranger/entraid/EntraIdUserGroupSource.java new file mode 100644 index 00000000000..014ff7fe504 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/EntraIdUserGroupSource.java @@ -0,0 +1,375 @@ +/* + * 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.ranger.entraid; + +import org.apache.commons.lang3.StringUtils; +import org.apache.ranger.entraid.graph.EntraIdGraphClient; +import org.apache.ranger.entraid.graph.EntraIdGraphClientImpl; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig; +import org.apache.ranger.entraid.graph.EntraIdGraphConfigLoader; +import org.apache.ranger.entraid.graph.GraphClientException; +import org.apache.ranger.ugsyncutil.model.EntraIdSyncSourceInfo; +import org.apache.ranger.ugsyncutil.model.UgsyncAuditInfo; +import org.apache.ranger.ugsyncutil.model.graph.DeltaEntry; +import org.apache.ranger.ugsyncutil.model.graph.DeltaPage; +import org.apache.ranger.ugsyncutil.model.graph.GraphGroup; +import org.apache.ranger.ugsyncutil.model.graph.GraphMemberRef; +import org.apache.ranger.ugsyncutil.model.graph.GraphUser; +import org.apache.ranger.ugsyncutil.model.graph.GroupMembershipPage; +import org.apache.ranger.ugsyncutil.model.graph.MemberType; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.apache.ranger.ugsyncutil.util.UgsyncCommonConstants; +import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; +import org.apache.ranger.usergroupsync.UserGroupSink; +import org.apache.ranger.usergroupsync.UserGroupSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class EntraIdUserGroupSource implements UserGroupSource { + private static final Logger LOG = LoggerFactory.getLogger(EntraIdUserGroupSource.class); + private static final String GRAPH_ATTR_MAIL = "mail"; + private static final String GRAPH_ATTR_UPN = "userPrincipalName"; + + private final UserGroupSyncConfig ugSyncConfig; + + private EntraIdGraphConfig config; + private EntraIdGraphClient graphClient; + private String currentSyncSource; + + // Delta state across cycles (in-process lifetime). + private String userDeltaLink; + private String groupDeltaLink; + private boolean firstSyncDone; + + // Delete-cycle cadence, mirroring LdapUserGroupBuilder / UnixUserGroupBuilder. + private int deleteCycles; + + public EntraIdUserGroupSource() { + this(UserGroupSyncConfig.getInstance()); + } + + EntraIdUserGroupSource(UserGroupSyncConfig ugSyncConfig) { + this.ugSyncConfig = ugSyncConfig; + } + + EntraIdUserGroupSource(UserGroupSyncConfig ugSyncConfig, EntraIdGraphConfig config, EntraIdGraphClient graphClient, String currentSyncSource) { + this.ugSyncConfig = ugSyncConfig; + this.config = config; + this.graphClient = graphClient; + this.currentSyncSource = currentSyncSource; + this.deleteCycles = 1; + } + + @Override + public void init() throws Throwable { + deleteCycles = 1; + currentSyncSource = ugSyncConfig.getCurrentSyncSource(); + this.config = new EntraIdGraphConfigLoader(ugSyncConfig).load(); + this.graphClient = new EntraIdGraphClientImpl(); + this.graphClient.init(config); + LOG.info("EntraIdUserGroupSource initialized: syncSource={}, graph={}, authMode={}, membershipMode={}", currentSyncSource, config.getGraphBaseUrl(), config.getAuthMode(), config.getMembershipMode()); + } + + @Override + public boolean isChanged() { + // Graph delta determines actual change at sync time; always attempt a cycle. + return true; + } + + @Override + public void updateSink(UserGroupSink sink) throws Throwable { + // 1. Decide cycle type. + // + // Normal cycle (the common case): incremental delta. Adds/updates ride the + // delta; deletions are applied per-record from Graph's @removed markers via + // sink.deleteUsersAndGroups() -- no full pull required. + // + // Safety-net cycle (rare, every reconcileFrequency-th delete cycle): a full + // snapshot with computeDeletes=true, to converge on any deletions Graph's + // delta may have missed (documented @removed gaps, delta-token expiry). + boolean deletesEnabled = ugSyncConfig.isUserSyncDeletesEnabled(); + boolean reconcileSweep = false; + int membershipFetchSkips = 0; + if (deletesEnabled && deleteCycles >= ugSyncConfig.getUserSyncDeletesFrequency()) { + deleteCycles = 1; + reconcileSweep = true; + LOG.debug("Full reconciliation sweep enabled for this sync cycle"); + } + if (deletesEnabled) { + deleteCycles++; + } + // A full pull is needed for the first cycle and for a reconciliation sweep. + boolean fullSync = reconcileSweep || !firstSyncDone; + String userToken = fullSync ? null : userDeltaLink; + String groupToken = fullSync ? null : groupDeltaLink; + + // 2. Pull users and groups from Graph. + DeltaPage userPage = orEmpty(graphClient.getUserDelta(userToken)); + // Group pull: on a full sync in DIRECT membership mode, fetch groups WITH inline + // membership in one delta enumeration (avoids one /members call per group -- the + // first-sync bottleneck). The inline path is valid ONLY for DIRECT mode: Graph's + // $select=members returns direct members only, with no transitive option on the + // delta endpoint. For TRANSITIVE mode we must use the per-group /transitiveMembers + // path even on full sync, otherwise a full sync would return direct members while + // incremental cycles return transitive members -- inconsistent membership for the + // same group. inlineMembers is non-null only when the inline path is used. + boolean useInlineMembers = fullSync && config.getMembershipMode() == MembershipMode.DIRECT; + DeltaPage groupPage; + GroupMembershipPage inlineMembers = null; + if (useInlineMembers) { + inlineMembers = orEmpty(graphClient.getGroupDeltaWithMembers(groupToken)); + groupPage = new DeltaPage<>(inlineMembers.getGroups(), inlineMembers.getDeltaLink(), inlineMembers.isResynced()); + } else { + groupPage = orEmpty(graphClient.getGroupDelta(groupToken)); + } + // If the client had to fall back to a full resync (delta link expired/invalidated), + // promote this cycle to a reconciliation sweep so deletions are computed by the + // sink's snapshot-diff (a resync does not replay @removed tombstones for objects + // deleted during the gap). + // + // CRITICAL: computeDeletes diffs the FULL cache against the passed snapshot, and a + // single computeDeletes flag covers BOTH users and groups. So if only one of the two + // delta links expired, the other is still an *incremental* page -- passing it with + // computeDeletes=true would mark every cached object absent from that small page as + // deleted (mass false-deletion). Therefore, if either resynced, force a full pull of + // the other as well so both snapshots are complete before computing deletes. + if (userPage.isResynced() || groupPage.isResynced()) { + LOG.warn("Delta resync occurred; forcing a full pull of both users and groups and treating this cycle as a reconciliation sweep"); + reconcileSweep = true; + if (!userPage.isResynced()) { + userPage = orEmpty(graphClient.getUserDelta(null)); + } + if (!groupPage.isResynced()) { + // Force a full group pull, keeping membership and attributes consistent for + // the snapshot-diff. Use inline membership only in DIRECT mode (see above); + // in TRANSITIVE mode fetch groups only and resolve members per-group below. + if (config.getMembershipMode() == MembershipMode.DIRECT) { + inlineMembers = graphClient.getGroupDeltaWithMembers(null); + groupPage = new DeltaPage<>(inlineMembers.getGroups(), inlineMembers.getDeltaLink(), inlineMembers.isResynced()); + } else { + groupPage = orEmpty(graphClient.getGroupDelta(null)); + } + } + } + // 3. Build the user snapshot (upserts) and the per-record user delete map, + // both keyed by GUID. + String nameAttr = chooseUserNameAttribute(); + Map> sourceUsers = new HashMap<>(); + Map> deletedUsers = new HashMap<>(); + long usersSynced = 0; + for (DeltaEntry entry : userPage.getEntries()) { + GraphUser user = entry.getValue(); + if (user == null || StringUtils.isBlank(user.getId())) { + continue; + } + if (entry.isRemoved()) { + // @removed on /users/delta means the user was deleted/disabled in the + // directory. Collect for per-record deletion (keyed by GUID, with the + // minimal attrs the sink's scoping gate matches on). On a reconcile + // sweep we let the full snapshot-diff compute deletes instead. + if (!reconcileSweep) { + deletedUsers.put(user.getId(), buildDeleteAttributes(user.getId())); + } + continue; + } + String userName = resolveUserName(user, nameAttr); + if (StringUtils.isBlank(userName)) { + continue; + } + sourceUsers.put(user.getId(), buildUserAttributes(user, userName)); + usersSynced++; + } + + // 4. Build the group snapshot, group->member-GUID map, and per-record group delete map, all keyed by GUID. + Map> sourceGroups = new HashMap<>(); + Map> sourceGroupUsers = new HashMap<>(); + Map> deletedGroups = new HashMap<>(); + long groupsSynced = 0; + MembershipMode mode = config.getMembershipMode(); + for (DeltaEntry entry : groupPage.getEntries()) { + GraphGroup group = entry.getValue(); + if (group == null || StringUtils.isBlank(group.getId())) { + continue; + } + if (entry.isRemoved()) { + if (!reconcileSweep) { + deletedGroups.put(group.getId(), buildDeleteAttributes(group.getId())); + } + continue; + } + String groupName = group.getDisplayName(); + if (StringUtils.isBlank(groupName)) { + continue; + } + sourceGroups.put(group.getId(), buildGroupAttributes(group, groupName)); + // Membership: when the inline path was used (DIRECT full sync), read the member + // set already merged across pages by the client (no per-group call). Otherwise + // (incremental cycle, or TRANSITIVE mode) fetch the complete current member set + // for this group. Either way the sink receives the full member set to diff. + Set memberGuids = null; + if (inlineMembers != null) { + memberGuids = inlineMembers.getMembers(group.getId()); + } else { + try { + memberGuids = fetchMemberGuids(group.getId(), mode); + } catch (GraphClientException e) { + // A single group that 404s mid-sync (e.g. deleted in Entra between the + // group-list pull and this member fetch) must not abort the whole cycle. + // Skip its membership this cycle and reconcile next cycle. Systemic + // failures (auth, throttling exhausted, network) carry a non-404 status + // or none, and are rethrown so a broken connection still fails loudly. + if (e.getHttpStatus() == 404) { + LOG.warn("EntraID: skipping membership for group {} (not found during member fetch): {}", group.getId(), e.getMessage()); + membershipFetchSkips++; + memberGuids = Collections.emptySet(); + } else { + throw e; // systemic failure -> fail the cycle + } + } + } + sourceGroupUsers.put(group.getId(), memberGuids); + groupsSynced++; + } + LOG.debug("EntraID snapshot: users={}, groups={}, deletedUsers={}, deletedGroups={}, fullSync={}, reconcileSweep={}", + sourceUsers.size(), sourceGroups.size(), deletedUsers.size(), deletedGroups.size(), fullSync, reconcileSweep); + // 5. Apply to the sink; advance delta tokens only on full success. + try { + sink.addOrUpdateUsersGroups(sourceGroups, sourceUsers, sourceGroupUsers, reconcileSweep); + // Per-record deletions (normal cycles only; the sweep computes deletes itself). + if (!reconcileSweep && (!deletedUsers.isEmpty() || !deletedGroups.isEmpty())) { + sink.deleteUsersAndGroups(deletedUsers, deletedGroups); + } + userDeltaLink = userPage.getDeltaLink(); + groupDeltaLink = groupPage.getDeltaLink(); + firstSyncDone = true; + LOG.info("EntraID sync cycle complete: users+~{}, groups+~{}, usersDeleted~{}, groupsDeleted~{}, membershipSkips={}, fullSync={}, reconcileSweep={}", + sourceUsers.size(), sourceGroups.size(), deletedUsers.size(), deletedGroups.size(), membershipFetchSkips, fullSync, reconcileSweep); + } catch (Throwable t) { + // Do not advance tokens; next cycle retries with the same state. + LOG.error("Failed to update ranger admin. Will retry in next sync cycle!!", t); + } + // 6. Emit audit (best-effort, like LDAP/Unix). + try { + sink.postUserGroupAuditInfo(buildAuditInfo(!fullSync, usersSynced, groupsSynced)); + } catch (Throwable t) { + LOG.error("sink.postUserGroupAuditInfo failed with exception: ", t); + } + } + + private Map buildDeleteAttributes(String guid) { + Map attrs = new HashMap<>(); + attrs.put(UgsyncCommonConstants.FULL_NAME, guid); + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, currentSyncSource); + return attrs; + } + + private String chooseUserNameAttribute() { + Set selected = config.getUserSelectAttrs(); + if (selected != null && selected.contains(GRAPH_ATTR_MAIL) && !selected.contains(GRAPH_ATTR_UPN)) { + return GRAPH_ATTR_MAIL; + } + return GRAPH_ATTR_UPN; + } + + private String resolveUserName(GraphUser user, String nameAttr) { + String name = GRAPH_ATTR_MAIL.equals(nameAttr) ? user.getMail() : user.getUserPrincipalName(); + if (StringUtils.isBlank(name)) { + name = GRAPH_ATTR_MAIL.equals(nameAttr) ? user.getUserPrincipalName() : user.getMail(); + } + return name; + } + + private Set fetchMemberGuids(String groupId, MembershipMode mode) throws Throwable { + Set memberGuids = new HashSet<>(); + List refs = graphClient.getGroupMembers(groupId, mode); + for (GraphMemberRef ref : refs) { + // Only user members map to Ranger group members. In DIRECT mode, nested + // GROUP members are not expanded (use TRANSITIVE for flattening). + if (ref.getType() == MemberType.USER && ref.getId() != null && !ref.getId().isEmpty()) { + memberGuids.add(ref.getId()); + } + } + return memberGuids; + } + + private Map buildUserAttributes(GraphUser user, String userName) { + Map attrs = new HashMap<>(); + // Contract-critical keys (read by the sink for add/update/delete reconciliation). + attrs.put(UgsyncCommonConstants.ORIGINAL_NAME, userName); + attrs.put(UgsyncCommonConstants.FULL_NAME, user.getId()); // GUID = stable identity + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, currentSyncSource); + // NOTE: LDAP_URL intentionally NOT set (mirrors Unix; enables delete reconciliation). + // Extra attributes (stored as otherAttributes; affect modify-detection). + attrs.put("cloud_id", user.getId()); + if (StringUtils.isNotBlank(user.getDisplayName())) { + attrs.put("displayName", user.getDisplayName()); + } + if (StringUtils.isNotBlank(user.getMail())) { + attrs.put("email", user.getMail()); + } + attrs.putAll(user.getAdditionalAttributes()); + return attrs; + } + + private Map buildGroupAttributes(GraphGroup group, String groupName) { + Map attrs = new HashMap<>(); + attrs.put(UgsyncCommonConstants.ORIGINAL_NAME, groupName); + attrs.put(UgsyncCommonConstants.FULL_NAME, group.getId()); + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, currentSyncSource); + attrs.put("cloud_id", group.getId()); + if (StringUtils.isNotBlank(group.getDisplayName())) { + attrs.put("displayName", group.getDisplayName()); + } + attrs.putAll(group.getAdditionalAttributes()); + return attrs; + } + + private UgsyncAuditInfo buildAuditInfo(boolean incremental, long usersSynced, long groupsSynced) { + EntraIdSyncSourceInfo sourceInfo = new EntraIdSyncSourceInfo(); + sourceInfo.setTenantId(config.getTenantId()); + sourceInfo.setGraphBaseUrl(config.getGraphBaseUrl()); + sourceInfo.setAuthMode(String.valueOf(config.getAuthMode())); + sourceInfo.setMembershipMode(String.valueOf(config.getMembershipMode())); + sourceInfo.setIncrementalSync(String.valueOf(incremental)); + sourceInfo.setGroupFilter(config.getGroupFilter()); + // Totals are authoritatively set by the sink (cache sizes + deletes) when it + // recognizes the EntraID source; these are a reasonable fallback otherwise. + sourceInfo.setTotalUsersSynced(usersSynced); + sourceInfo.setTotalGroupsSynced(groupsSynced); + UgsyncAuditInfo auditInfo = new UgsyncAuditInfo(); + auditInfo.setSyncSource(currentSyncSource); + auditInfo.setEntraIdSyncSourceInfo(sourceInfo); + return auditInfo; + } + + private static DeltaPage orEmpty(DeltaPage page) { + return page != null ? page : new DeltaPage<>(Collections.emptyList(), null); + } + + private static GroupMembershipPage orEmpty(GroupMembershipPage page) { + return page != null ? page : new GroupMembershipPage(Collections.emptyList(), new HashMap<>(), null, false); + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/CertificateTokenProvider.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/CertificateTokenProvider.java new file mode 100644 index 00000000000..6ec10b82138 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/CertificateTokenProvider.java @@ -0,0 +1,144 @@ +/* + * 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.ranger.entraid.graph; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.ws.rs.client.Client; +import javax.ws.rs.core.Form; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.Key; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +final class CertificateTokenProvider extends OAuthTokenProvider { + private static final String JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + private static final long ASSERTION_LIFETIME_SECONDS = 300L; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private PrivateKey privateKey; + private String x5tThumbprint; + + @Override + public void init(EntraIdGraphConfig config, Client httpClient) throws GraphClientException { + super.init(config, httpClient); + loadKeyMaterial(config); + } + + @Override + protected void addCredential(Form form) throws GraphClientException { + form.param("client_assertion_type", JWT_BEARER_ASSERTION_TYPE); + form.param("client_assertion", buildSignedAssertion()); + } + + private void loadKeyMaterial(EntraIdGraphConfig config) throws GraphClientException { + String path = config.getKeystorePath(); + char[] password = config.getKeystorePassword(); + String alias = config.getCertAlias(); + + try (InputStream in = Files.newInputStream(Paths.get(path))) { + KeyStore keyStore = KeyStore.getInstance(inferKeystoreType(path)); + keyStore.load(in, password); + Key key = keyStore.getKey(alias, password); + if (!(key instanceof PrivateKey)) { + throw new GraphClientException("Alias '" + alias + "' in keystore " + path + " does not resolve to a private key"); + } + this.privateKey = (PrivateKey) key; + Certificate cert = keyStore.getCertificate(alias); + if (!(cert instanceof X509Certificate)) { + throw new GraphClientException("Alias '" + alias + "' in keystore " + path + " does not have an X.509 certificate"); + } + this.x5tThumbprint = computeX5t((X509Certificate) cert); + } catch (GraphClientException e) { + throw e; + } catch (Exception e) { + throw new GraphClientException("Failed to load certificate key material from keystore " + path, e); + } finally { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + } + + private String buildSignedAssertion() throws GraphClientException { + try { + long now = Instant.now().getEpochSecond(); + Map header = new LinkedHashMap<>(); + header.put("alg", "RS256"); + header.put("typ", "JWT"); + header.put("x5t", x5tThumbprint); + Map claims = new LinkedHashMap<>(); + claims.put("aud", getTokenEndpoint()); + claims.put("iss", getClientId()); + claims.put("sub", getClientId()); + claims.put("jti", UUID.randomUUID().toString()); + claims.put("nbf", now); + claims.put("exp", now + ASSERTION_LIFETIME_SECONDS); + claims.put("iat", now); + String encodedHeader = base64Url(objectMapper.writeValueAsBytes(header)); + String encodedClaims = base64Url(objectMapper.writeValueAsBytes(claims)); + String signingInput = encodedHeader + "." + encodedClaims; + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + signature.update(signingInput.getBytes(StandardCharsets.UTF_8)); + String encodedSignature = base64Url(signature.sign()); + return signingInput + "." + encodedSignature; + } catch (Exception e) { + throw new GraphClientException("Failed to build signed client assertion", e); + } + } + + private static String computeX5t(X509Certificate cert) throws GraphClientException { + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + return base64Url(sha1.digest(cert.getEncoded())); + } catch (Exception e) { + throw new GraphClientException("Failed to compute certificate thumbprint (x5t)", e); + } + } + + private static String inferKeystoreType(String path) { + String lower = path.toLowerCase(); + if (lower.endsWith(".jks")) { + return "JKS"; + } + if (lower.endsWith(".bcfks")) { + return "BCFKS"; + } + return "PKCS12"; + } + + private static String base64Url(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/ClientSecretTokenProvider.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/ClientSecretTokenProvider.java new file mode 100644 index 00000000000..1ece44f0f24 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/ClientSecretTokenProvider.java @@ -0,0 +1,50 @@ +/* + * 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.ranger.entraid.graph; + +import javax.ws.rs.client.Client; +import javax.ws.rs.core.Form; + +import java.util.Arrays; + +final class ClientSecretTokenProvider extends OAuthTokenProvider { + private char[] clientSecret; + + @Override + public void init(EntraIdGraphConfig config, Client httpClient) throws GraphClientException { + super.init(config, httpClient); + char[] secret = config.getClientSecret(); + if (secret == null || secret.length == 0) { + throw new GraphClientException("clientSecret is required for CLIENT_SECRET authentication"); + } + this.clientSecret = secret; + } + + @Override + protected void addCredential(Form form) { + form.param("client_secret", new String(clientSecret)); + } + + void destroy() { + if (clientSecret != null) { + Arrays.fill(clientSecret, '\0'); + clientSecret = null; + } + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/DeltaResyncRequiredException.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/DeltaResyncRequiredException.java new file mode 100644 index 00000000000..68cac959163 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/DeltaResyncRequiredException.java @@ -0,0 +1,34 @@ +/* + * 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.ranger.entraid.graph; + +import java.io.Serial; + +/** + * Signals that a Microsoft Graph delta link can no longer be used and the caller + * must restart the delta cycle with a full synchronization. + */ +public class DeltaResyncRequiredException extends GraphClientException { + @Serial + private static final long serialVersionUID = 1L; + + public DeltaResyncRequiredException(String message, int httpStatus) { + super(message, httpStatus); + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClient.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClient.java new file mode 100644 index 00000000000..c28474c691a --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClient.java @@ -0,0 +1,44 @@ +/* + * 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.ranger.entraid.graph; + +import org.apache.ranger.ugsyncutil.model.graph.DeltaPage; +import org.apache.ranger.ugsyncutil.model.graph.GraphGroup; +import org.apache.ranger.ugsyncutil.model.graph.GraphMemberRef; +import org.apache.ranger.ugsyncutil.model.graph.GraphUser; +import org.apache.ranger.ugsyncutil.model.graph.GroupMembershipPage; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; + +import java.io.Closeable; +import java.util.List; + +public interface EntraIdGraphClient extends Closeable { + void init(EntraIdGraphConfig config) throws GraphClientException; + + DeltaPage getUserDelta(String deltaLink) throws GraphClientException; + + DeltaPage getGroupDelta(String deltaLink) throws GraphClientException; + + GroupMembershipPage getGroupDeltaWithMembers(String deltaLink) throws GraphClientException; + + List getGroupMembers(String groupId, MembershipMode mode) throws GraphClientException; + + @Override + void close() throws GraphClientException; +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClientImpl.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClientImpl.java new file mode 100644 index 00000000000..4020bd6b2f4 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphClientImpl.java @@ -0,0 +1,483 @@ +/* + * 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.ranger.entraid.graph; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ranger.plugin.util.RangerJersey2ClientBuilder; +import org.apache.ranger.ugsyncutil.model.graph.DeltaEntry; +import org.apache.ranger.ugsyncutil.model.graph.DeltaPage; +import org.apache.ranger.ugsyncutil.model.graph.GraphGroup; +import org.apache.ranger.ugsyncutil.model.graph.GraphMemberRef; +import org.apache.ranger.ugsyncutil.model.graph.GraphUser; +import org.apache.ranger.ugsyncutil.model.graph.GroupMembershipPage; +import org.apache.ranger.ugsyncutil.model.graph.MemberType; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.client.Client; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class EntraIdGraphClientImpl implements EntraIdGraphClient { + private static final Logger LOG = LoggerFactory.getLogger(EntraIdGraphClientImpl.class); + + private static final String API_VERSION = "v1.0"; + private static final String ODATA_NEXTLINK = "@odata.nextLink"; + private static final String ODATA_DELTALINK = "@odata.deltaLink"; + private static final String ODATA_REMOVED = "@removed"; + private static final String MEMBERS_DELTA = "members@delta"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private EntraIdGraphConfig config; + private Client httpClient; + private TokenProvider tokenProvider; + + @Override + public void init(EntraIdGraphConfig config) throws GraphClientException { + if (config == null) { + throw new GraphClientException("config must not be null"); + } + this.config = config; + this.httpClient = RangerJersey2ClientBuilder.createClient(config.getConnectTimeoutMs(), config.getReadTimeoutMs()); + switch (config.getAuthMode()) { + case CERTIFICATE: + this.tokenProvider = new CertificateTokenProvider(); + break; + case CLIENT_SECRET: + this.tokenProvider = new ClientSecretTokenProvider(); + break; + default: + throw new GraphClientException("Unsupported auth mode: " + config.getAuthMode()); + } + this.tokenProvider.init(config, httpClient); + this.tokenProvider.getAccessToken(false); + LOG.info("Initialized Entra Graph client: graph={}, authMode={}, membershipMode={}", config.getGraphBaseUrl(), config.getAuthMode(), config.getMembershipMode()); + } + + @Override + public DeltaPage getUserDelta(String deltaLink) throws GraphClientException { + String baseUrl = buildInitialDeltaUrl("users", config.getUserSelectAttrs()); + try { + return pageUsers((deltaLink != null) ? deltaLink : baseUrl); + } catch (DeltaResyncRequiredException e) { + if (deltaLink == null) { + throw e; // already a full pull; nothing to fall back to + } + LOG.warn("User delta link expired; restarting with a full user sync. Cause: {}", e.getMessage()); + DeltaPage full = pageUsers(baseUrl); + return new DeltaPage<>(full.getEntries(), full.getDeltaLink(), true); // resynced=true + } + } + + private DeltaPage pageUsers(String startUrl) throws GraphClientException { + List> entries = new ArrayList<>(); + String nextDelta = null; + String currentUrl = startUrl; + while (currentUrl != null) { + JsonNode page = executeGet(currentUrl); + for (JsonNode node : valueArray(page)) { + boolean removed = node.has(ODATA_REMOVED); + GraphUser user = mapUser(node); + entries.add(new DeltaEntry<>(user, removed)); + } + currentUrl = textOrNull(page, ODATA_NEXTLINK); + String dl = textOrNull(page, ODATA_DELTALINK); + if (dl != null) { + nextDelta = dl; + } + } + return new DeltaPage<>(entries, nextDelta); + } + + @Override + public DeltaPage getGroupDelta(String deltaLink) throws GraphClientException { + // Group membership is fetched separately and authoritatively via getGroupMembers + // (the full member set, which the sink diffs against its cache). The group delta + // is therefore used only to track group objects and their attributes, so the + // group attribute $select always applies. + String baseUrl = buildInitialDeltaUrl("groups", config.getGroupSelectAttrs()); + try { + return pageGroups((deltaLink != null) ? deltaLink : baseUrl); + } catch (DeltaResyncRequiredException e) { + if (deltaLink == null) { + throw e; + } + LOG.warn("Group delta link expired; restarting with a full group sync. Cause: {}", e.getMessage()); + DeltaPage full = pageGroups(baseUrl); + return new DeltaPage<>(full.getEntries(), full.getDeltaLink(), true); + } + } + + private DeltaPage pageGroups(String startUrl) throws GraphClientException { + List> entries = new ArrayList<>(); + String nextDelta = null; + String currentUrl = startUrl; + while (currentUrl != null) { + JsonNode page = executeGet(currentUrl); + for (JsonNode node : valueArray(page)) { + boolean removed = node.has(ODATA_REMOVED); + GraphGroup group = mapGroup(node); + entries.add(new DeltaEntry<>(group, removed)); + } + currentUrl = textOrNull(page, ODATA_NEXTLINK); + String dl = textOrNull(page, ODATA_DELTALINK); + if (dl != null) { + nextDelta = dl; + } + } + return new DeltaPage<>(entries, nextDelta); + } + + @Override + public GroupMembershipPage getGroupDeltaWithMembers(String deltaLink) throws GraphClientException { + // Full-sync path: fetch groups AND their membership inline via $select=...,members, + // so we avoid one /members call per group. Membership for a single large group can be + // split across delta pages, so pageGroupsWithMembers merges per group id across pages. + String baseUrl = buildGroupDeltaUrlWithMembers(); + try { + return pageGroupsWithMembers((deltaLink != null) ? deltaLink : baseUrl, false); + } catch (DeltaResyncRequiredException e) { + if (deltaLink == null) { + throw e; + } + LOG.warn("Group(+members) delta link expired; restarting with a full group sync. Cause: {}", e.getMessage()); + return pageGroupsWithMembers(baseUrl, true); + } + } + + private GroupMembershipPage pageGroupsWithMembers(String startUrl, boolean resynced) throws GraphClientException { + // Dedup group objects across page splits (first occurrence defines attributes) and + // accumulate each group's user-member ids across all pages. + Map groupsById = new LinkedHashMap<>(); + Map removedById = new LinkedHashMap<>(); + Map> membersById = new HashMap<>(); + String nextDelta = null; + String currentUrl = startUrl; + while (currentUrl != null) { + JsonNode page = executeGet(currentUrl); + for (JsonNode node : valueArray(page)) { + String id = textOrNull(node, "id"); + if (id == null || id.isEmpty()) { + continue; + } + // First occurrence defines the group's attributes and removed flag. + groupsById.putIfAbsent(id, mapGroup(node)); + removedById.putIfAbsent(id, node.has(ODATA_REMOVED)); + // Accumulate this page's chunk of members for the group. + Set members = membersById.computeIfAbsent(id, k -> new LinkedHashSet<>()); + JsonNode inline = node.get(MEMBERS_DELTA); + if (inline != null && inline.isArray()) { + for (JsonNode m : inline) { + String memberId = textOrNull(m, "id"); + if (memberId == null || memberId.isEmpty()) { + continue; + } + if (m.has(ODATA_REMOVED)) { + members.remove(memberId); // defensive; rare on a full pull + } else if (isUserMember(m)) { + members.add(memberId); // users only (DIRECT mode) + } + } + } + } + currentUrl = textOrNull(page, ODATA_NEXTLINK); + String dl = textOrNull(page, ODATA_DELTALINK); + if (dl != null) { + nextDelta = dl; + } + } + List> entries = new ArrayList<>(); + for (Map.Entry e : groupsById.entrySet()) { + entries.add(new DeltaEntry<>(e.getValue(), Boolean.TRUE.equals(removedById.get(e.getKey())))); + } + return new GroupMembershipPage(entries, membersById, nextDelta, resynced); + } + + /** + * True when an inline members@delta entry is a user (by @odata.type). + */ + private boolean isUserMember(JsonNode memberNode) { + String type = textOrNull(memberNode, "@odata.type"); + // Inline members@delta carries @odata.type (unlike the type-cast /members segment). + // Absent type is treated as non-user to avoid mis-adding directory objects. + return type != null && type.toLowerCase().endsWith("user"); + } + + private String buildGroupDeltaUrlWithMembers() { + StringBuilder sb = new StringBuilder(config.getGraphBaseUrl()).append("/").append(API_VERSION).append("/groups/delta"); + // $select must include 'members' to get inline membership; always include id, and + // carry the configured group attributes so the group objects are fully populated. + Set attrs = new LinkedHashSet<>(); + attrs.add("id"); + if (config.getGroupSelectAttrs() != null) { + attrs.addAll(config.getGroupSelectAttrs()); + } + attrs.add("members"); + StringBuilder query = new StringBuilder(); + query.append("$select=").append(encode(String.join(",", attrs))); + appendAmp(query).append("$top=").append(config.getPageSize()); + return sb.append("?").append(query).toString(); + } + + @Override + public List getGroupMembers(String groupId, MembershipMode mode) throws GraphClientException { + if (groupId == null || groupId.isEmpty()) { + throw new GraphClientException("groupId must not be empty"); + } + String relation = (mode == MembershipMode.TRANSITIVE) ? "transitiveMembers" : "members"; + String url = config.getGraphBaseUrl() + "/" + API_VERSION + "/groups/" + groupId + "/" + relation + "/microsoft.graph.user?$select=id&$top=" + config.getPageSize(); + List members = new ArrayList<>(); + String currentUrl = url; + while (currentUrl != null) { + JsonNode page = executeGet(currentUrl); + for (JsonNode node : valueArray(page)) { + String id = textOrNull(node, "id"); + if (id != null && !id.isEmpty()) { + members.add(new GraphMemberRef(id, MemberType.USER, node.has(ODATA_REMOVED))); + } + } + currentUrl = textOrNull(page, ODATA_NEXTLINK); + } + return members; + } + + @Override + public void close() throws GraphClientException { + if (httpClient != null) { + try { + httpClient.close(); + } catch (Exception e) { + throw new GraphClientException("Failed to close Graph HTTP client", e); + } finally { + httpClient = null; + } + } + } + + private String buildInitialDeltaUrl(String entity, Set selectAttrs) throws GraphClientException { + StringBuilder sb = new StringBuilder(config.getGraphBaseUrl()).append("/").append(API_VERSION).append("/").append(entity).append("/delta"); + StringBuilder query = new StringBuilder(); + if (selectAttrs != null && !selectAttrs.isEmpty()) { + Set attrs = new LinkedHashSet<>(); + attrs.add("id"); + attrs.addAll(selectAttrs); + query.append("$select=").append(encode(String.join(",", attrs))); + } + // NOTE: Microsoft Graph does not support attribute-based $filter on the /delta + // endpoints -- the only accepted $filter is "id eq {guid}" (max 50 ids). Sending + // any other $filter to /users/delta or /groups/delta returns 400 Request_Unsupported + // Query. Attribute-based scoping must therefore be applied client-side; it is not + // attempted in this phase (see proposal: group scoping is a follow-up, implemented + // client-side following the Microsoft Entra provisioning pattern of delta + post-filter). + /*if (config.getGroupFilter() != null && !config.getGroupFilter().isEmpty() && "groups".equals(entity)) { + appendAmp(query).append("$filter=").append(encode(config.getGroupFilter())); + }*/ + appendAmp(query).append("$top=").append(config.getPageSize()); + return sb.append("?").append(query).toString(); + } + + private static StringBuilder appendAmp(StringBuilder sb) { + if (!sb.isEmpty()) { + sb.append("&"); + } + return sb; + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * Defense-in-depth against a poisoned @odata.nextLink / @odata.deltaLink: only follow + * pagination/delta URLs whose host matches the configured Graph base URL. This prevents + * the bearer token from being sent to an attacker-influenced host (e.g. a cloud metadata + * endpoint) if a Graph response were ever tampered with. Comparison is host-only and + * case-insensitive; scheme and port are also required to match the configured base. + */ + private void assertSameHostAsGraph(String url) throws GraphClientException { + final URI target; + final URI base; + try { + target = new URI(url); + base = new URI(config.getGraphBaseUrl()); + } catch (URISyntaxException e) { + throw new GraphClientException("Refusing to fetch malformed URL: " + url, e); + } + String targetHost = target.getHost(); + String baseHost = base.getHost(); + if (targetHost == null || !targetHost.equalsIgnoreCase(baseHost) || !schemeEquals(base, target) || base.getPort() != target.getPort()) { + throw new GraphClientException("Refusing to follow URL outside the configured Graph host (" + baseHost + "): " + url); + } + } + + private static boolean schemeEquals(URI a, URI b) { + return a.getScheme() != null && a.getScheme().equalsIgnoreCase(b.getScheme()); + } + + private JsonNode executeGet(String url) throws GraphClientException { + assertSameHostAsGraph(url); + int attempt = 0; + boolean refreshedOnce = false; + while (true) { + String token = tokenProvider.getAccessToken(false); + try (Response response = httpClient.target(url).request(MediaType.APPLICATION_JSON).header("Authorization", "Bearer " + token) + // ConsistencyLevel: eventual opts into Entra ID's eventual-consistency read model and is + // required for advanced queries ($count, $search, advanced $filter, $orderby). It is also + // commonly used for directory-object delta queries and is harmless on the calls made here. + .header("ConsistencyLevel", "eventual").get()) { + int status = response.getStatus(); + if (status >= 200 && status < 300) { + String body = response.hasEntity() ? response.readEntity(String.class) : "{}"; + return objectMapper.readTree(body); + } + if (status == 401 && !refreshedOnce) { + refreshedOnce = true; + tokenProvider.getAccessToken(true); + continue; + } + if ((status == 429 || status == 503) && attempt < config.getMaxRetries()) { + long waitMs = backoffMillis(response, attempt); + LOG.warn("Graph throttled (HTTP {}); backing off {} ms (attempt {}/{})", status, waitMs, attempt + 1, config.getMaxRetries()); + sleep(waitMs); + attempt++; + continue; + } + String body = response.hasEntity() ? response.readEntity(String.class) : ""; + // Delta link expiry / invalidation -> caller must restart with a full sync. + // 410 Gone : tenant maintenance / migration + // 400 syncStateNotFound : delta token expired (7-day limit for directory objects) + // The Location header on a 410 is not always reliable, so recovery re-seeds + // from our own base delta URL rather than following it. + if (status == 410 || (status == 400 && body != null && (body.contains("syncStateNotFound") || body.contains("resyncRequired")))) { + throw new DeltaResyncRequiredException("Graph delta link expired/invalid: GET " + url + " -> HTTP " + status + " " + truncate(body), status); + } + throw new GraphClientException("Graph request failed: GET " + url + " -> HTTP " + status + " " + truncate(body), status); + } catch (GraphClientException e) { + throw e; + } catch (Exception e) { + if (attempt < config.getMaxRetries()) { + long waitMs = config.getRetryBaseBackoffMs() * (1L << attempt); + LOG.warn("Transient error on GET {} ({}); retrying in {} ms (attempt {}/{})", url, e.toString(), waitMs, attempt + 1, config.getMaxRetries()); + sleep(waitMs); + attempt++; + continue; + } + throw new GraphClientException("Graph request failed after retries: GET " + url, e); + } + } + } + + private long backoffMillis(Response response, int attempt) { + String retryAfter = response.getHeaderString("Retry-After"); + if (retryAfter != null && !retryAfter.isEmpty()) { + try { + return Long.parseLong(retryAfter.trim()) * 1000L; + } catch (NumberFormatException ignored) { + // fall back to exponential backoff + } + } + return config.getRetryBaseBackoffMs() * (1L << attempt); + } + + private static void sleep(long millis) throws GraphClientException { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GraphClientException("Interrupted while backing off", e); + } + } + + private GraphUser mapUser(JsonNode node) { + GraphUser user = new GraphUser(); + user.setId(textOrNull(node, "id")); + user.setUserPrincipalName(textOrNull(node, "userPrincipalName")); + user.setMail(textOrNull(node, "mail")); + user.setDisplayName(textOrNull(node, "displayName")); + if (node.has("accountEnabled") && !node.get("accountEnabled").isNull()) { + user.setAccountEnabled(node.get("accountEnabled").asBoolean(true)); + } + applyAdditionalAttributes(node, config.getUserSelectAttrs(), user::putAdditionalAttribute); + return user; + } + + private GraphGroup mapGroup(JsonNode node) { + GraphGroup group = new GraphGroup(); + group.setId(textOrNull(node, "id")); + group.setDisplayName(textOrNull(node, "displayName")); + group.setMailNickname(textOrNull(node, "mailNickname")); + if (node.has("securityEnabled") && !node.get("securityEnabled").isNull()) { + group.setSecurityEnabled(node.get("securityEnabled").asBoolean(true)); + } + applyAdditionalAttributes(node, config.getGroupSelectAttrs(), group::putAdditionalAttribute); + return group; + } + + private interface AttrSink { + void put(String key, String value); + } + + private void applyAdditionalAttributes(JsonNode node, Set selectAttrs, AttrSink sink) { + if (selectAttrs == null || selectAttrs.isEmpty()) { + return; + } + for (String attr : selectAttrs) { + JsonNode v = node.get(attr); + if (v != null && !v.isNull() && v.isValueNode()) { + sink.put(attr, v.asText()); + } + } + } + + private Iterable valueArray(JsonNode page) { + JsonNode value = page.get("value"); + if (value == null || !value.isArray()) { + return Collections.emptyList(); + } + return value::elements; + } + + private static String textOrNull(JsonNode node, String field) { + JsonNode v = node.get(field); + return (v == null || v.isNull()) ? null : v.asText(); + } + + private static String truncate(String s) { + if (s == null) { + return ""; + } + return s.length() <= 500 ? s : s.substring(0, 500) + "..."; + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfig.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfig.java new file mode 100644 index 00000000000..5ad6847235c --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfig.java @@ -0,0 +1,347 @@ +/* + * 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.ranger.entraid.graph; + +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class EntraIdGraphConfig { + public enum AuthMode { CERTIFICATE, CLIENT_SECRET } + + private static final String OAUTH2_TOKEN_PATH_SUFFIX = "/oauth2/v2.0/token"; + private static final String DEFAULT_SCOPE_SUFFIX = "/.default"; + private final String authorityHost; + private final String graphBaseUrl; + private final String tenantId; + private final String clientId; + + private final AuthMode authMode; + private final String keystorePath; + private final char[] keystorePassword; + private final String certAlias; + private final char[] clientSecret; + + private final MembershipMode membershipMode; + private final Set userSelectAttrs; + private final Set groupSelectAttrs; + private final String groupFilter; + private final int pageSize; + + private final int maxRetries; + private final long retryBaseBackoffMs; + private final int connectTimeoutMs; + private final int readTimeoutMs; + + private EntraIdGraphConfig(Builder b) { + this.authorityHost = stripTrailingSlashes(b.authorityHost); + this.graphBaseUrl = stripTrailingSlashes(b.graphBaseUrl); + this.tenantId = b.tenantId; + this.clientId = b.clientId; + this.authMode = b.authMode; + this.keystorePath = b.keystorePath; + this.keystorePassword = b.keystorePassword == null ? null : b.keystorePassword.clone(); + this.certAlias = b.certAlias; + this.clientSecret = b.clientSecret == null ? null : b.clientSecret.clone(); + this.membershipMode = b.membershipMode; + this.userSelectAttrs = unmodifiableCopy(b.userSelectAttrs); + this.groupSelectAttrs = unmodifiableCopy(b.groupSelectAttrs); + this.groupFilter = b.groupFilter; + this.pageSize = b.pageSize; + this.maxRetries = b.maxRetries; + this.retryBaseBackoffMs = b.retryBaseBackoffMs; + this.connectTimeoutMs = b.connectTimeoutMs; + this.readTimeoutMs = b.readTimeoutMs; + } + + private static Set unmodifiableCopy(Set in) { + if (in == null || in.isEmpty()) { + return Collections.emptySet(); + } + return Collections.unmodifiableSet(new LinkedHashSet<>(in)); + } + + private static String stripTrailingSlashes(String s) { + return s == null ? null : s.replaceAll("/+$", ""); + } + + public String getTokenEndpoint() { + return authorityHost + "/" + tenantId + OAUTH2_TOKEN_PATH_SUFFIX; + } + + public String getDefaultScope() { + return graphBaseUrl + DEFAULT_SCOPE_SUFFIX; + } + + public String getAuthorityHost() { + return authorityHost; + } + + public String getGraphBaseUrl() { + return graphBaseUrl; + } + + public String getTenantId() { + return tenantId; + } + + public String getClientId() { + return clientId; + } + + public AuthMode getAuthMode() { + return authMode; + } + + public String getKeystorePath() { + return keystorePath; + } + + public char[] getKeystorePassword() { + return keystorePassword == null ? null : keystorePassword.clone(); + } + + public String getCertAlias() { + return certAlias; + } + + public char[] getClientSecret() { + return clientSecret == null ? null : clientSecret.clone(); + } + + public MembershipMode getMembershipMode() { + return membershipMode; + } + + public Set getUserSelectAttrs() { + return userSelectAttrs; + } + + public Set getGroupSelectAttrs() { + return groupSelectAttrs; + } + + public String getGroupFilter() { + return groupFilter; + } + + public int getPageSize() { + return pageSize; + } + + public int getMaxRetries() { + return maxRetries; + } + + public long getRetryBaseBackoffMs() { + return retryBaseBackoffMs; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public int getReadTimeoutMs() { + return readTimeoutMs; + } + + @Override + public String toString() { + return "EntraIdGraphConfig{authorityHost=" + authorityHost + + ", graphBaseUrl=" + graphBaseUrl + + ", tenantId=" + tenantId + + ", clientId=" + clientId + + ", authMode=" + authMode + + ", membershipMode=" + membershipMode + + ", userSelectAttrs=" + userSelectAttrs + + ", groupSelectAttrs=" + groupSelectAttrs + + ", groupFilter=" + (groupFilter == null ? "none" : groupFilter) + + ", pageSize=" + pageSize + + ", maxRetries=" + maxRetries + + ", retryBaseBackoffMs=" + retryBaseBackoffMs + + ", connectTimeoutMs=" + connectTimeoutMs + + ", readTimeoutMs=" + readTimeoutMs + + '}'; + } + + public static final class Builder { + // endpoints — defaults = global Azure commercial cloud + private String authorityHost = "https://login.microsoftonline.com"; + private String graphBaseUrl = "https://graph.microsoft.com"; + + private String tenantId; + private String clientId; + + private AuthMode authMode; + private String keystorePath; + private char[] keystorePassword; + private String certAlias; + private char[] clientSecret; + + private MembershipMode membershipMode = MembershipMode.DIRECT; + private Set userSelectAttrs; + private Set groupSelectAttrs; + private String groupFilter; + private int pageSize = 999; + + private int maxRetries = 5; + private long retryBaseBackoffMs = 1000L; + private int connectTimeoutMs = 30_000; + private int readTimeoutMs = 60_000; + + public Builder authorityHost(String v) { + this.authorityHost = v; + return this; + } + + public Builder graphBaseUrl(String v) { + this.graphBaseUrl = v; + return this; + } + + public Builder tenantId(String v) { + this.tenantId = v; + return this; + } + + public Builder clientId(String v) { + this.clientId = v; + return this; + } + + public Builder authMode(AuthMode v) { + this.authMode = v; + return this; + } + + public Builder keystorePath(String v) { + this.keystorePath = v; + return this; + } + + public Builder keystorePassword(char[] v) { + this.keystorePassword = v == null ? null : v.clone(); + return this; + } + + public Builder certAlias(String v) { + this.certAlias = v; + return this; + } + + public Builder clientSecret(char[] v) { + this.clientSecret = v == null ? null : v.clone(); + return this; + } + + public Builder membershipMode(MembershipMode v) { + this.membershipMode = v == null ? MembershipMode.DIRECT : v; + return this; + } + + public Builder userSelectAttrs(Set v) { + this.userSelectAttrs = v; + return this; + } + + public Builder groupSelectAttrs(Set v) { + this.groupSelectAttrs = v; + return this; + } + + public Builder groupFilter(String v) { + this.groupFilter = v; + return this; + } + + public Builder pageSize(int v) { + this.pageSize = v; + return this; + } + + public Builder maxRetries(int v) { + this.maxRetries = v; + return this; + } + + public Builder retryBaseBackoffMs(long v) { + this.retryBaseBackoffMs = v; + return this; + } + + public Builder connectTimeoutMs(int v) { + this.connectTimeoutMs = v; + return this; + } + + public Builder readTimeoutMs(int v) { + this.readTimeoutMs = v; + return this; + } + + public EntraIdGraphConfig build() { + requireText(tenantId, "tenantId"); + requireText(clientId, "clientId"); + requireText(authorityHost, "authorityHost"); + requireText(graphBaseUrl, "graphBaseUrl"); + + if (authMode == null) { + throw new IllegalStateException("authMode must be set (CERTIFICATE or CLIENT_SECRET)"); + } + + if (authMode == AuthMode.CERTIFICATE) { + requireText(keystorePath, "keystorePath (required for CERTIFICATE auth)"); + requireText(certAlias, "certAlias (required for CERTIFICATE auth)"); + if (clientSecret != null) { + throw new IllegalStateException("clientSecret must not be set when authMode=CERTIFICATE"); + } + } else { // CLIENT_SECRET + if (clientSecret == null || clientSecret.length == 0) { + throw new IllegalStateException("clientSecret is required for CLIENT_SECRET auth"); + } + if (keystorePath != null) { + throw new IllegalStateException("keystorePath must not be set when authMode=CLIENT_SECRET"); + } + } + + if (pageSize < 1) { + throw new IllegalStateException("pageSize must be >= 1"); + } + if (maxRetries < 0) { + throw new IllegalStateException("maxRetries must be >= 0"); + } + if (retryBaseBackoffMs < 0) { + throw new IllegalStateException("retryBaseBackoffMs must be >= 0"); + } + if (connectTimeoutMs < 0 || readTimeoutMs < 0) { + throw new IllegalStateException("timeouts must be >= 0"); + } + + return new EntraIdGraphConfig(this); + } + + private static void requireText(String v, String name) { + if (v == null || v.trim().isEmpty()) { + throw new IllegalStateException(name + " must not be blank"); + } + } + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfigLoader.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfigLoader.java new file mode 100644 index 00000000000..4ca09130d0f --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/EntraIdGraphConfigLoader.java @@ -0,0 +1,203 @@ +/* + * 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.ranger.entraid.graph; + +import org.apache.commons.lang3.StringUtils; +import org.apache.ranger.credentialapi.CredentialReader; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig.AuthMode; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.function.Consumer; +import java.util.function.IntConsumer; +import java.util.function.LongConsumer; + +public final class EntraIdGraphConfigLoader { + static final String ENTRAID_TENANT_ID = "ranger.usersync.entraid.tenant.id"; + static final String ENTRAID_CLIENT_ID = "ranger.usersync.entraid.client.id"; + static final String ENTRAID_AUTH_TYPE = "ranger.usersync.entraid.auth.type"; + static final String ENTRAID_AUTHORITY_HOST = "ranger.usersync.entraid.authority.host"; + static final String ENTRAID_GRAPH_BASE_URL = "ranger.usersync.entraid.graph.base.url"; + + static final String ENTRAID_KEYSTORE_FILE = "ranger.usersync.entraid.keystore.file"; + static final String ENTRAID_KEYSTORE_PASSWORD = "ranger.usersync.entraid.keystore.password"; + static final String ENTRAID_KEYSTORE_ALIAS = "ranger.usersync.entraid.keystore.credential.alias"; + static final String ENTRAID_CERT_ALIAS = "ranger.usersync.entraid.cert.alias"; + + static final String ENTRAID_CLIENT_SECRET = "ranger.usersync.entraid.client.secret"; + static final String ENTRAID_CLIENT_SECRET_ALIAS = "ranger.usersync.entraid.client.secret.credential.alias"; + + static final String ENTRAID_CREDSTORE_FILE = "ranger.usersync.credstore.filename"; + static final String RANGER_KEYSTORE_TYPE = "ranger.keystore.file.type"; + + static final String ENTRAID_MEMBERSHIP_MODE = "ranger.usersync.entraid.membership.mode"; + static final String ENTRAID_USER_SELECT_ATTRS = "ranger.usersync.entraid.user.select.attributes"; + static final String ENTRAID_GROUP_SELECT_ATTRS = "ranger.usersync.entraid.group.select.attributes"; + static final String ENTRAID_GROUP_FILTER = "ranger.usersync.entraid.group.filter"; + static final String ENTRAID_PAGE_SIZE = "ranger.usersync.entraid.page.size"; + + static final String ENTRAID_MAX_RETRIES = "ranger.usersync.entraid.max.retries"; + static final String ENTRAID_RETRY_BASE_BACKOFF = "ranger.usersync.entraid.retry.base.backoff.ms"; + static final String ENTRAID_CONNECT_TIMEOUT_MS = "ranger.usersync.entraid.connect.timeout.ms"; + static final String ENTRAID_READ_TIMEOUT_MS = "ranger.usersync.entraid.read.timeout.ms"; + + private final UserGroupSyncConfig config; + + public EntraIdGraphConfigLoader(UserGroupSyncConfig config) { + if (config == null) { + throw new IllegalArgumentException("UserGroupSyncConfig must not be null"); + } + this.config = config; + } + + public EntraIdGraphConfig load() throws GraphClientException { + EntraIdGraphConfig.Builder builder = new EntraIdGraphConfig.Builder() + .tenantId(trimmed(ENTRAID_TENANT_ID)) + .clientId(trimmed(ENTRAID_CLIENT_ID)) + .membershipMode(parseMembershipMode(trimmed(ENTRAID_MEMBERSHIP_MODE))) + .userSelectAttrs(parseCsv(getProperty(ENTRAID_USER_SELECT_ATTRS))) + .groupSelectAttrs(parseCsv(getProperty(ENTRAID_GROUP_SELECT_ATTRS))) + .groupFilter(trimmed(ENTRAID_GROUP_FILTER)); + + applyIfPresent(ENTRAID_AUTHORITY_HOST, builder::authorityHost); + applyIfPresent(ENTRAID_GRAPH_BASE_URL, builder::graphBaseUrl); + applyIntIfPresent(ENTRAID_PAGE_SIZE, builder::pageSize); + applyIntIfPresent(ENTRAID_MAX_RETRIES, builder::maxRetries); + applyLongIfPresent(ENTRAID_RETRY_BASE_BACKOFF, builder::retryBaseBackoffMs); + applyIntIfPresent(ENTRAID_CONNECT_TIMEOUT_MS, builder::connectTimeoutMs); + applyIntIfPresent(ENTRAID_READ_TIMEOUT_MS, builder::readTimeoutMs); + AuthMode authMode = parseAuthMode(trimmed(ENTRAID_AUTH_TYPE)); + builder.authMode(authMode); + if (authMode == AuthMode.CERTIFICATE) { + builder.keystorePath(trimmed(ENTRAID_KEYSTORE_FILE)).certAlias(trimmed(ENTRAID_CERT_ALIAS)); + char[] keystorePassword = resolveSecret(ENTRAID_KEYSTORE_PASSWORD, ENTRAID_KEYSTORE_ALIAS); + if (keystorePassword != null) { + builder.keystorePassword(keystorePassword); + Arrays.fill(keystorePassword, '\0'); + } + } else { // CLIENT_SECRET + char[] clientSecret = resolveSecret(ENTRAID_CLIENT_SECRET, ENTRAID_CLIENT_SECRET_ALIAS); + if (clientSecret != null) { + builder.clientSecret(clientSecret); + Arrays.fill(clientSecret, '\0'); + } + } + try { + return builder.build(); + } catch (RuntimeException e) { + throw new GraphClientException("Invalid EntraID UserSync configuration: " + e.getMessage(), e); + } + } + + private char[] resolveSecret(String directProp, String aliasProp) { + String direct = getProperty(directProp); + if (StringUtils.isNotBlank(direct)) { + return direct.trim().toCharArray(); + } + String credStorePath = getProperty(ENTRAID_CREDSTORE_FILE); + String alias = getProperty(aliasProp); + if (StringUtils.isBlank(credStorePath) || StringUtils.isBlank(alias)) { + return null; + } + String storeType = config.getProperty(RANGER_KEYSTORE_TYPE); + String decrypted = CredentialReader.getDecryptedString(credStorePath.trim(), alias.trim(), storeType); + if (StringUtils.isBlank(decrypted) || "none".equalsIgnoreCase(decrypted.trim())) { + return null; + } + return decrypted.toCharArray(); + } + + private AuthMode parseAuthMode(String value) throws GraphClientException { + if (StringUtils.isBlank(value)) { + return AuthMode.CERTIFICATE; + } + try { + return AuthMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new GraphClientException(ENTRAID_AUTH_TYPE + " must be CERTIFICATE or CLIENT_SECRET, got: " + value); + } + } + + private MembershipMode parseMembershipMode(String value) throws GraphClientException { + if (StringUtils.isBlank(value)) { + return MembershipMode.DIRECT; + } + try { + return MembershipMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new GraphClientException(ENTRAID_MEMBERSHIP_MODE + " must be DIRECT or TRANSITIVE, got: " + value); + } + } + + private static Set parseCsv(String value) { + Set result = new LinkedHashSet<>(); + if (StringUtils.isNotBlank(value)) { + StringTokenizer st = new StringTokenizer(value, ","); + while (st.hasMoreTokens()) { + String token = st.nextToken().trim(); + if (!token.isEmpty()) { + result.add(token); + } + } + } + return result; + } + + private String getProperty(String name) { + return config.getProperty(name); + } + + private String trimmed(String name) { + String value = config.getProperty(name); + return value == null ? null : value.trim(); + } + + private void applyIfPresent(String name, Consumer setter) { + String value = trimmed(name); + if (StringUtils.isNotBlank(value)) { + setter.accept(value); + } + } + + private void applyIntIfPresent(String name, IntConsumer setter) throws GraphClientException { + String value = trimmed(name); + if (StringUtils.isNotBlank(value)) { + try { + setter.accept(Integer.parseInt(value)); + } catch (NumberFormatException e) { + throw new GraphClientException(name + " must be an integer, got: " + value); + } + } + } + + private void applyLongIfPresent(String name, LongConsumer setter) throws GraphClientException { + String value = trimmed(name); + if (StringUtils.isNotBlank(value)) { + try { + setter.accept(Long.parseLong(value)); + } catch (NumberFormatException e) { + throw new GraphClientException(EntraIdGraphConfigLoader.ENTRAID_RETRY_BASE_BACKOFF + " must be a long, got: " + value); + } + } + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/GraphClientException.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/GraphClientException.java new file mode 100644 index 00000000000..b4a15827074 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/GraphClientException.java @@ -0,0 +1,53 @@ +/* + * 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.ranger.entraid.graph; + +import java.io.IOException; +import java.io.Serial; + +public class GraphClientException extends IOException { + @Serial + private static final long serialVersionUID = 1L; + + private final int httpStatus; + + public GraphClientException(String message) { + this(message, -1, null); + } + + public GraphClientException(String message, Throwable cause) { + this(message, -1, cause); + } + + public GraphClientException(String message, int httpStatus) { + this(message, httpStatus, null); + } + + public GraphClientException(String message, int httpStatus, Throwable cause) { + super(message, cause); + this.httpStatus = httpStatus; + } + + public int getHttpStatus() { + return httpStatus; + } + + public boolean isHttpError() { + return httpStatus > 0; + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/OAuthTokenProvider.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/OAuthTokenProvider.java new file mode 100644 index 00000000000..1be8aa1a9b1 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/OAuthTokenProvider.java @@ -0,0 +1,135 @@ +/* + * 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.ranger.entraid.graph; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import java.time.Instant; + +abstract class OAuthTokenProvider implements TokenProvider { + private static final Logger LOG = LoggerFactory.getLogger(OAuthTokenProvider.class); + /** Renew this many seconds before the server-stated expiry to avoid races. */ + private static final long EXPIRY_SAFETY_SECONDS = 120L; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private Client client; + private String tokenEndpoint; + private String scope; + private String clientId; + + private String cachedToken; + private Instant cachedTokenExpiry; + + @Override + public void init(EntraIdGraphConfig config, Client httpClient) throws GraphClientException { + this.client = httpClient; + this.tokenEndpoint = config.getTokenEndpoint(); + this.scope = config.getDefaultScope(); + this.clientId = config.getClientId(); + } + + @Override + public String getAccessToken(boolean forceRefresh) throws GraphClientException { + if (!forceRefresh && cachedToken != null && cachedTokenExpiry != null && Instant.now().isBefore(cachedTokenExpiry)) { + return cachedToken; + } + return acquireNewToken(); + } + + protected String getTokenEndpoint() { + return tokenEndpoint; + } + + protected String getClientId() { + return clientId; + } + + /** + * Add the credential-specific parameters to the token request form. + * For a client secret this is {@code client_secret}; for a certificate this + * is {@code client_assertion_type} + {@code client_assertion}. + */ + protected abstract void addCredential(Form form) throws GraphClientException; + + private synchronized String acquireNewToken() throws GraphClientException { + Form form = new Form(); + form.param("client_id", clientId); + form.param("scope", scope); + form.param("grant_type", "client_credentials"); + addCredential(form); + try (Response response = client.target(tokenEndpoint).request(MediaType.APPLICATION_JSON).post(Entity.form(form))) { + int status = response.getStatus(); + String body = response.hasEntity() ? response.readEntity(String.class) : ""; + if (status != 200) { + throw new GraphClientException("Token request to " + tokenEndpoint + " failed: " + summarizeError(body), status); + } + JsonNode root = objectMapper.readTree(body); + JsonNode accessToken = root.get("access_token"); + JsonNode expiresIn = root.get("expires_in"); + if (accessToken == null || accessToken.asText().isEmpty()) { + throw new GraphClientException("Token response did not contain access_token"); + } + long ttlSeconds = expiresIn == null ? 3600L : expiresIn.asLong(3600L); + long effective = Math.max(ttlSeconds - EXPIRY_SAFETY_SECONDS, 1L); + cachedToken = accessToken.asText(); + cachedTokenExpiry = Instant.now().plusSeconds(effective); + LOG.debug("Acquired Entra access token; expires in {}s (effective {}s)", ttlSeconds, effective); + return cachedToken; + } catch (GraphClientException e) { + throw e; + } catch (Exception e) { + throw new GraphClientException("Failed to acquire access token from " + tokenEndpoint, e); + } + } + + /** Surface the OAuth error code without leaking the full token-endpoint payload. */ + private String summarizeError(String body) { + if (body == null || body.isEmpty()) { + return "empty response"; + } + try { + JsonNode root = objectMapper.readTree(body); + JsonNode err = root.get("error"); + JsonNode desc = root.get("error_description"); + if (err != null) { + String code = err.asText(); + // error_description can be verbose; keep only its first line. + String detail = desc == null ? "" : desc.asText(); + int nl = detail.indexOf('\n'); + if (nl > 0) { + detail = detail.substring(0, nl); + } + return detail.isEmpty() ? code : (code + ": " + detail); + } + } catch (JsonProcessingException ignored) { + // fall through + } + return "unparseable error response"; + } +} diff --git a/ugsync/src/main/java/org/apache/ranger/entraid/graph/TokenProvider.java b/ugsync/src/main/java/org/apache/ranger/entraid/graph/TokenProvider.java new file mode 100644 index 00000000000..5fa83ef9f58 --- /dev/null +++ b/ugsync/src/main/java/org/apache/ranger/entraid/graph/TokenProvider.java @@ -0,0 +1,27 @@ +/* + * 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.ranger.entraid.graph; + +import javax.ws.rs.client.Client; + +interface TokenProvider { + void init(EntraIdGraphConfig config, Client httpClient) throws GraphClientException; + + String getAccessToken(boolean forceRefresh) throws GraphClientException; +} diff --git a/ugsync/src/main/java/org/apache/ranger/unixusersync/config/UserGroupSyncConfig.java b/ugsync/src/main/java/org/apache/ranger/unixusersync/config/UserGroupSyncConfig.java index eb501ea12bc..ae4786851f9 100644 --- a/ugsync/src/main/java/org/apache/ranger/unixusersync/config/UserGroupSyncConfig.java +++ b/ugsync/src/main/java/org/apache/ranger/unixusersync/config/UserGroupSyncConfig.java @@ -78,6 +78,7 @@ public class UserGroupSyncConfig { private static final String UGSYNC_SOURCE_CLASS = "org.apache.ranger.unixusersync.process.UnixUserGroupBuilder"; private static final String UGSYNC_SINK_CLASS = "org.apache.ranger.unixusersync.process.PolicyMgrUserGroupBuilder"; private static final String LGSYNC_SOURCE_CLASS = "org.apache.ranger.ldapusersync.process.LdapUserGroupBuilder"; + private static final String EGSYNC_SOURCE_CLASS = "org.apache.ranger.entraid.EntraIdUserGroupSource"; /* LDAP Configs */ private static final String LGSYNC_LDAP_URL = "ranger.usersync.ldap.url"; private static final String LGSYNC_LDAP_AUTHENTICATION_MECHANISM = "ranger.usersync.ldap.authentication.mechanism"; @@ -1343,6 +1344,8 @@ public String getCurrentSyncSource() throws Throwable { currentSyncSource = "LDAP/AD"; } else if (UGSYNC_SOURCE_CLASS.equalsIgnoreCase(className)) { currentSyncSource = "Unix"; + } else if (EGSYNC_SOURCE_CLASS.equalsIgnoreCase(className)) { + currentSyncSource = "EntraID"; } else { currentSyncSource = "File"; } diff --git a/ugsync/src/main/java/org/apache/ranger/unixusersync/process/PolicyMgrUserGroupBuilder.java b/ugsync/src/main/java/org/apache/ranger/unixusersync/process/PolicyMgrUserGroupBuilder.java index b42700cd887..f05abc5c156 100644 --- a/ugsync/src/main/java/org/apache/ranger/unixusersync/process/PolicyMgrUserGroupBuilder.java +++ b/ugsync/src/main/java/org/apache/ranger/unixusersync/process/PolicyMgrUserGroupBuilder.java @@ -271,6 +271,12 @@ public void postUserGroupAuditInfo(UgsyncAuditInfo ugsyncAuditInfo) { ugsyncAuditInfo.getFileSyncSourceInfo().setTotalUsersDeleted(noOfDeletedUsers); ugsyncAuditInfo.getFileSyncSourceInfo().setTotalGroupsDeleted(noOfDeletedGroups); break; + case "EntraID": + ugsyncAuditInfo.getEntraIdSyncSourceInfo().setTotalUsersSynced(noOfCachedUsers); + ugsyncAuditInfo.getEntraIdSyncSourceInfo().setTotalGroupsSynced(noOfCachedGroups); + ugsyncAuditInfo.getEntraIdSyncSourceInfo().setTotalUsersDeleted(noOfDeletedUsers); + ugsyncAuditInfo.getEntraIdSyncSourceInfo().setTotalGroupsDeleted(noOfDeletedGroups); + break; default: break; } @@ -1940,6 +1946,82 @@ private int getDeletedUsers() throws Throwable { return ret; } + @Override + public void deleteUsersAndGroups(Map> deletedUsersMap, Map> deletedGroupsMap) throws Throwable { + LOG.debug("==> PolicyMgrUserGroupBuilder.deleteUsersAndGroups(users={}, groups={})", MapUtils.isNotEmpty(deletedUsersMap) ? deletedUsersMap.keySet() : "[]", MapUtils.isNotEmpty(deletedGroupsMap) ? deletedGroupsMap.keySet() : "[]"); + if (isStartupFlag) { + LOG.info("Skipping per-record deletes during startup cycle"); + return; + } + if (MapUtils.isNotEmpty(deletedGroupsMap)) { + markDeletedGroupsByFullName(deletedGroupsMap.keySet()); + if (MapUtils.isNotEmpty(deletedGroups)) { + if (updateDeletedGroups() == 0) { + String msg = "Failed to update deleted groups to ranger admin"; + LOG.error(msg); + throw new Exception(msg); + } + groupCache.putAll(deletedGroups); + noOfDeletedGroups += deletedGroups.size(); + } + LOG.info("No. of groups marked for delete (per-record) = {}", deletedGroups.size()); + } + if (MapUtils.isNotEmpty(deletedUsersMap)) { + markDeletedUsersByFullName(deletedUsersMap.keySet()); + if (MapUtils.isNotEmpty(deletedUsers)) { + if (updateDeletedUsers() == 0) { + String msg = "Failed to update deleted users to ranger admin"; + LOG.error(msg); + throw new Exception(msg); + } + userCache.putAll(deletedUsers); + noOfDeletedUsers += deletedUsers.size(); + } + LOG.info("No. of users marked for delete (per-record) = {}", deletedUsers.size()); + } + LOG.debug("<== PolicyMgrUserGroupBuilder.deleteUsersAndGroups()"); + } + + private void markDeletedGroupsByFullName(Set deletedGroupFullNames) { + LOG.debug("PolicyMgrUserGroupBuilder.markDeletedGroupsByFullName({})", deletedGroupFullNames); + deletedGroups = new HashMap<>(); + for (XGroupInfo groupInfo : groupCache.values()) { + Map groupOtherAttrs = groupInfo.getOtherAttrsMap(); + String groupDN = groupOtherAttrs != null ? groupOtherAttrs.get(UgsyncCommonConstants.FULL_NAME) : null; + if (StringUtils.isNotEmpty(groupDN) && deletedGroupFullNames.contains(groupDN) + && StringUtils.equalsIgnoreCase(groupOtherAttrs.get(UgsyncCommonConstants.SYNC_SOURCE), currentSyncSource) + && StringUtils.equalsIgnoreCase(groupOtherAttrs.get(UgsyncCommonConstants.LDAP_URL), ldapUrl)) { + if (!ISHIDDEN.equals(groupInfo.getIsVisible())) { + groupInfo.setIsVisible(ISHIDDEN); + deletedGroups.put(groupInfo.getName(), groupInfo); + } else { + LOG.info("group {} already marked for delete", groupInfo.getName()); + } + } + } + LOG.debug("<== PolicyMgrUserGroupBuilder.markDeletedGroupsByFullName({})", deletedGroups); + } + + private void markDeletedUsersByFullName(Set deletedUserFullNames) { + LOG.debug("PolicyMgrUserGroupBuilder.markDeletedUsersByFullName({})", deletedUserFullNames); + deletedUsers = new HashMap<>(); + for (XUserInfo userInfo : userCache.values()) { + Map userOtherAttrs = userInfo.getOtherAttrsMap(); + String userDN = userOtherAttrs != null ? userOtherAttrs.get(UgsyncCommonConstants.FULL_NAME) : null; + if (StringUtils.isNotEmpty(userDN) && deletedUserFullNames.contains(userDN) + && StringUtils.equalsIgnoreCase(userOtherAttrs.get(UgsyncCommonConstants.SYNC_SOURCE), currentSyncSource) + && StringUtils.equalsIgnoreCase(userOtherAttrs.get(UgsyncCommonConstants.LDAP_URL), ldapUrl)) { + if (!ISHIDDEN.equals(userInfo.getIsVisible())) { + userInfo.setIsVisible(ISHIDDEN); + deletedUsers.put(userInfo.getName(), userInfo); + } else { + LOG.info("user {} already marked for delete", userInfo.getName()); + } + } + } + LOG.debug("<== PolicyMgrUserGroupBuilder.markDeletedUsersByFullName({})", deletedUsers); + } + // This will throw RuntimeException if Server is not Active private void checkStatus() { if (!UserGroupSyncConfig.isUgsyncServiceActive()) { diff --git a/ugsync/src/main/java/org/apache/ranger/usergroupsync/UserGroupSink.java b/ugsync/src/main/java/org/apache/ranger/usergroupsync/UserGroupSink.java index 5dad3af5f84..c987ad01de2 100644 --- a/ugsync/src/main/java/org/apache/ranger/usergroupsync/UserGroupSink.java +++ b/ugsync/src/main/java/org/apache/ranger/usergroupsync/UserGroupSink.java @@ -33,4 +33,8 @@ void addOrUpdateUsersGroups(Map> sourceGroups, Map> sourceUsers, Map> sourceGroupUsers, boolean computeDeletes) throws Throwable; + + default void deleteUsersAndGroups(Map> deletedUsers, Map> deletedGroups) throws Throwable { + // No-op by default. Snapshot-diff sources do not use this path. + } } diff --git a/ugsync/src/test/java/org/apache/ranger/entraid/TestEntraIdUserGroupSource.java b/ugsync/src/test/java/org/apache/ranger/entraid/TestEntraIdUserGroupSource.java new file mode 100644 index 00000000000..ef0871dbd5f --- /dev/null +++ b/ugsync/src/test/java/org/apache/ranger/entraid/TestEntraIdUserGroupSource.java @@ -0,0 +1,611 @@ +/* + * 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.ranger.entraid; + +import org.apache.ranger.entraid.graph.EntraIdGraphClient; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig; +import org.apache.ranger.entraid.graph.GraphClientException; +import org.apache.ranger.ugsyncutil.model.graph.DeltaEntry; +import org.apache.ranger.ugsyncutil.model.graph.DeltaPage; +import org.apache.ranger.ugsyncutil.model.graph.GraphGroup; +import org.apache.ranger.ugsyncutil.model.graph.GraphMemberRef; +import org.apache.ranger.ugsyncutil.model.graph.GraphUser; +import org.apache.ranger.ugsyncutil.model.graph.GroupMembershipPage; +import org.apache.ranger.ugsyncutil.model.graph.MemberType; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.apache.ranger.ugsyncutil.util.UgsyncCommonConstants; +import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; +import org.apache.ranger.usergroupsync.UserGroupSink; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class TestEntraIdUserGroupSource { + private static final String SYNC_SOURCE = "EntraID"; + private static final String USER_GUID = "aaaaaaaa-0000-0000-0000-000000000001"; + private static final String USER_UPN = "alice@example.com"; + private static final String GROUP_GUID = "bbbbbbbb-0000-0000-0000-000000000002"; + private static final String GROUP_NAME = "Engineering"; + + private UserGroupSyncConfig ugSyncConfig; + private EntraIdGraphConfig graphConfig; + private EntraIdGraphClient graphClient; + private UserGroupSink sink; + + @BeforeEach + public void setUp() throws GraphClientException { + ugSyncConfig = Mockito.mock(UserGroupSyncConfig.class, Mockito.withSettings().lenient()); + graphConfig = Mockito.mock(EntraIdGraphConfig.class, Mockito.withSettings().lenient()); + graphClient = Mockito.mock(EntraIdGraphClient.class, Mockito.withSettings().lenient()); + sink = Mockito.mock(UserGroupSink.class, Mockito.withSettings().lenient()); + Mockito.when(graphConfig.getMembershipMode()).thenReturn(MembershipMode.DIRECT); + // Default for full-sync (DIRECT) tests that don't assert on groups: an empty inline + // members page, so the inline path returns non-null. Group-asserting tests override. + Mockito.lenient().when(graphClient.getGroupDeltaWithMembers(Mockito.any())).thenReturn(membersPage("gDelta")); + } + + // Build a GroupMembershipPage (inline-members full-sync return) from group entries. + @SafeVarargs + private GroupMembershipPage membersPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new GroupMembershipPage(list, new HashMap<>(), deltaLink, false); + } + + // Build a GroupMembershipPage with an explicit groupId -> member-GUIDs membership map. + @SafeVarargs + private GroupMembershipPage membersPage(Map> membersByGroupId, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new GroupMembershipPage(list, membersByGroupId, "gDelta", false); + } + + // A GroupMembershipPage flagged resynced (inline-path equivalent of a resynced group page). + @SafeVarargs + private GroupMembershipPage resyncedGroupMembersPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new GroupMembershipPage(list, new HashMap<>(), deltaLink, true); + } + + // A resynced DeltaPage (used when groups come via the non-inline getGroupDelta path). + @SafeVarargs + private DeltaPage resyncedGroupPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new DeltaPage<>(list, deltaLink, true); + } + + private EntraIdUserGroupSource newSource() throws Throwable { + return new EntraIdUserGroupSource(ugSyncConfig, graphConfig, graphClient, SYNC_SOURCE); + } + + private GraphUser user() { + GraphUser u = new GraphUser(); + u.setId(TestEntraIdUserGroupSource.USER_GUID); + u.setUserPrincipalName(TestEntraIdUserGroupSource.USER_UPN); + return u; + } + + private GraphGroup group(String groupId, String name) { + GraphGroup g = new GraphGroup(); + g.setId(groupId); + g.setDisplayName(name); + return g; + } + + @SafeVarargs + private DeltaPage userPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new DeltaPage<>(list, deltaLink); + } + + @SafeVarargs + private DeltaPage resyncedUserPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new DeltaPage<>(list, deltaLink, true); + } + + @SafeVarargs + private DeltaPage groupPage(String deltaLink, DeltaEntry... entries) { + List> list = new ArrayList<>(); + Collections.addAll(list, entries); + return new DeltaPage<>(list, deltaLink); + } + + @SuppressWarnings("unchecked") + private Map>[] captureUserAndGroupMaps() throws Throwable { + ArgumentCaptor>> groups = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor>> users = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor>> gusers = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor del = ArgumentCaptor.forClass(Boolean.class); + Mockito.verify(sink).addOrUpdateUsersGroups(groups.capture(), users.capture(), gusers.capture(), del.capture()); + return new Map[] {groups.getValue(), users.getValue()}; + } + + private GraphMemberRef userRef(String guid) { + return GraphMemberRef.added(guid, MemberType.USER); + } + + @Test + public void test01_users_keyedByGuid_notUsername() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map>[] maps = captureUserAndGroupMaps(); + Map> users = maps[1]; + Assertions.assertTrue(users.containsKey(USER_GUID), "user map must be keyed by object-id GUID"); + Assertions.assertFalse(users.containsKey(USER_UPN), "user map must not be keyed by UPN"); + } + + @Test + public void test02_user_carriesOriginalNameAsUpn() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map attrs = captureUserAndGroupMaps()[1].get(USER_GUID); + Assertions.assertEquals(USER_UPN, attrs.get(UgsyncCommonConstants.ORIGINAL_NAME)); + } + + @Test + public void test03_user_fullNameIsGuid() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map attrs = captureUserAndGroupMaps()[1].get(USER_GUID); + Assertions.assertEquals(USER_GUID, attrs.get(UgsyncCommonConstants.FULL_NAME)); + } + + @Test + public void test04_user_syncSourceIsSet() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map attrs = captureUserAndGroupMaps()[1].get(USER_GUID); + Assertions.assertEquals(SYNC_SOURCE, attrs.get(UgsyncCommonConstants.SYNC_SOURCE)); + } + + @Test + public void test05_user_ldapUrlIsNotSet() throws Throwable { + // Mirroring Unix: omitting LDAP_URL is what lets the sink's null==null delete gate apply. + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map attrs = captureUserAndGroupMaps()[1].get(USER_GUID); + Assertions.assertFalse(attrs.containsKey(UgsyncCommonConstants.LDAP_URL)); + } + + @Test + public void test06_removedUser_isExcludedFromUpsertSnapshot() throws Throwable { + // deletes enabled, frequency high so this is a NORMAL (non-sweep) cycle. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), true))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map> users = captureUserAndGroupMaps()[1]; + Assertions.assertTrue(users.isEmpty(), "removed entries must not appear in the upsert snapshot"); + } + + @Test + public void test07_group_keyedByGuid() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta", new DeltaEntry<>(group(GROUP_GUID, GROUP_NAME), false))); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map> groups = captureUserAndGroupMaps()[0]; + Assertions.assertTrue(groups.containsKey(GROUP_GUID)); + Assertions.assertEquals(GROUP_NAME, groups.get(GROUP_GUID).get(UgsyncCommonConstants.ORIGINAL_NAME)); + } + + @Test + public void test08_membership_flowsFromInlinePageToSink() throws Throwable { + // On the full-sync DIRECT path, membership comes from the inline GroupMembershipPage + // map (already user-filtered by the client; see client test for type exclusion). + // This asserts the source passes that membership through to the sink, keyed by group. + Map> membership = new HashMap<>(); + membership.put(GROUP_GUID, new LinkedHashSet<>(List.of(USER_GUID))); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage(membership, new DeltaEntry<>(group(GROUP_GUID, GROUP_NAME), false))); + ArgumentCaptor>> gusers = ArgumentCaptor.forClass(Map.class); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Mockito.verify(sink).addOrUpdateUsersGroups(Mockito.any(), Mockito.any(), gusers.capture(), Mockito.anyBoolean()); + Set members = gusers.getValue().get(GROUP_GUID); + Assertions.assertTrue(members.contains(USER_GUID), "inline membership must flow to the sink keyed by group"); + } + + @Test + public void test09_firstCycle_passesComputeDeletesFalse() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(false); + Mockito.when(graphClient.getUserDelta(Mockito.any())).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDelta(Mockito.any())).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + ArgumentCaptor del = ArgumentCaptor.forClass(Boolean.class); + Mockito.verify(sink).addOrUpdateUsersGroups(Mockito.any(), Mockito.any(), Mockito.any(), del.capture()); + Assertions.assertFalse(del.getValue(), "deletes disabled -> computeDeletes must be false"); + } + + @Test + public void test10_reconcileSweep_passesComputeDeletesTrue() throws Throwable { + // deletes enabled, frequency 1 -> first cycle is already a reconcile-sweep cycle. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(1L); + Mockito.when(graphClient.getUserDelta(Mockito.any())).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDelta(Mockito.any())).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + ArgumentCaptor del = ArgumentCaptor.forClass(Boolean.class); + Mockito.verify(sink).addOrUpdateUsersGroups(Mockito.any(), Mockito.any(), Mockito.any(), del.capture()); + Assertions.assertTrue(del.getValue(), "reconcile sweep -> computeDeletes must be true"); + } + + @Test + public void test11_reconcileSweep_forcesFullPull_nullDeltaToken() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(1L); + Mockito.when(graphClient.getUserDelta(Mockito.any())).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDeltaWithMembers(Mockito.any())).thenReturn(membersPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + // A reconcile sweep must request a full snapshot (delta token == null). Groups come + // via the inline path in DIRECT mode. + Mockito.verify(graphClient).getUserDelta(null); + Mockito.verify(graphClient).getGroupDeltaWithMembers(null); + } + + @Test + public void test16_normalCycle_removedUser_callsDeleteWithGuid() throws Throwable { + // Normal cycle (frequency high so no sweep): a @removed user must be sent to + // sink.deleteUsersAndGroups keyed by GUID, NOT to the upsert path. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), true))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + ArgumentCaptor>> delUsers = ArgumentCaptor.forClass(Map.class); + Mockito.verify(sink).deleteUsersAndGroups(delUsers.capture(), Mockito.any()); + Assertions.assertTrue(delUsers.getValue().containsKey(USER_GUID), "deleted user must be keyed by GUID"); + Assertions.assertEquals(USER_GUID, delUsers.getValue().get(USER_GUID).get(UgsyncCommonConstants.FULL_NAME)); + Assertions.assertEquals(SYNC_SOURCE, delUsers.getValue().get(USER_GUID).get(UgsyncCommonConstants.SYNC_SOURCE)); + } + + @Test + public void test17_normalCycle_removedGroup_callsDeleteWithGuid() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta", new DeltaEntry<>(group(GROUP_GUID, GROUP_NAME), true))); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + ArgumentCaptor>> delGroups = ArgumentCaptor.forClass(Map.class); + Mockito.verify(sink).deleteUsersAndGroups(Mockito.any(), delGroups.capture()); + Assertions.assertTrue(delGroups.getValue().containsKey(GROUP_GUID), "deleted group must be keyed by GUID"); + } + + @Test + public void test18_normalCycle_doesNotForceFullPull() throws Throwable { + // Establish a delta token on cycle 1, then assert cycle 2 (normal) uses it + // rather than forcing a full pull -- the key scale property for Bosch. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-1")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta-1")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // cycle 1: first sync, full pull, captures tokens + Mockito.when(graphClient.getUserDelta("uDelta-1")).thenReturn(userPage("uDelta-2")); + Mockito.when(graphClient.getGroupDelta("gDelta-1")).thenReturn(groupPage("gDelta-2")); + source.updateSink(sink); // cycle 2: normal cycle must use the delta token + Mockito.verify(graphClient).getUserDelta("uDelta-1"); + Mockito.verify(graphClient).getGroupDelta("gDelta-1"); + } + + @Test + public void test19_reconcileSweep_doesNotCallPerRecordDelete() throws Throwable { + // On a sweep, deletions are computed by the sink's snapshot-diff (computeDeletes=true), + // so the per-record delete path must NOT be invoked even if @removed entries arrive. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(1L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), true))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Mockito.verify(sink, Mockito.never()).deleteUsersAndGroups(Mockito.any(), Mockito.any()); + } + + @Test + public void test20_normalCycle_noRemovals_doesNotCallDelete() throws Throwable { + // No @removed entries -> the per-record delete path must not be invoked at all. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Mockito.verify(sink, Mockito.never()).deleteUsersAndGroups(Mockito.any(), Mockito.any()); + } + + @Test + public void test21_bothResynced_promotesToSweep_computeDeletesTrue() throws Throwable { + // Both pages came from a forced resync -> cycle must become a reconciliation sweep + // (computeDeletes=true), and per-record deletes must NOT fire. + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(resyncedUserPage("uDelta", new DeltaEntry<>(user(), true))); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(resyncedGroupMembersPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + ArgumentCaptor del = ArgumentCaptor.forClass(Boolean.class); + Mockito.verify(sink).addOrUpdateUsersGroups(Mockito.any(), Mockito.any(), Mockito.any(), del.capture()); + Assertions.assertTrue(del.getValue(), "a resync must force computeDeletes=true (reconcile sweep)"); + // Per-record delete path must be suppressed on a sweep even though @removed was present. + Mockito.verify(sink, Mockito.never()).deleteUsersAndGroups(Mockito.any(), Mockito.any()); + } + + @Test + public void test22_onlyUserResynced_forcesFullGroupPull() throws Throwable { + // DANGEROUS PARTIAL CASE: only the user delta expired. The group page would still be + // incremental; passing it with computeDeletes=true would mass-delete groups. The source + // must force a FULL group pull before computing deletes. In DIRECT mode that full pull + // uses the inline-members path (getGroupDeltaWithMembers). + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + // Cycle 1 (full, DIRECT) establishes tokens via the inline path. + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-1")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta-1")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // cycle 1: full pull, tokens captured + // Cycle 2: user delta link expired -> resynced user page; group delta succeeds + // incrementally (NOT resynced) off the persisted token. + Mockito.when(graphClient.getUserDelta("uDelta-1")).thenReturn(resyncedUserPage("uDelta-2")); + Mockito.when(graphClient.getGroupDelta("gDelta-1")).thenReturn(groupPage("gDelta-2")); + source.updateSink(sink); + // getGroupDeltaWithMembers(null): cycle 1 full pull + cycle 2 forced full re-pull = 2. + Mockito.verify(graphClient, Mockito.times(2)).getGroupDeltaWithMembers(null); + } + + @Test + public void test23_onlyGroupResynced_forcesFullUserPull() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-1")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta-1")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // cycle 1 + // Cycle 2: group delta expired (resynced), user delta incremental. On an incremental + // cycle groups come via getGroupDelta (not the inline path), so the resync flag must + // arrive on that method. + Mockito.when(graphClient.getUserDelta("uDelta-1")).thenReturn(userPage("uDelta-2")); + Mockito.when(graphClient.getGroupDelta("gDelta-1")).thenReturn(resyncedGroupPage("gDelta-2")); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-full")); + source.updateSink(sink); + // getUserDelta(null) called on cycle 1 + forced re-pull = 2. + Mockito.verify(graphClient, Mockito.times(2)).getUserDelta(null); + } + + @Test + public void test24_transitiveFullSync_usesPerGroupFetch_notInlineMembers() throws Throwable { + // In TRANSITIVE mode, $select=members (inline) returns only DIRECT members, so a full + // sync must NOT use the inline path -- it must fall back to the per-group members fetch + // (which resolves /transitiveMembers). Otherwise a full sync and an incremental cycle + // would return different member sets for the same group. + Mockito.when(graphConfig.getMembershipMode()).thenReturn(MembershipMode.TRANSITIVE); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta", new DeltaEntry<>(group(GROUP_GUID, GROUP_NAME), false))); + Mockito.when(graphClient.getGroupMembers(Mockito.anyString(), Mockito.any())).thenReturn(Collections.emptyList()); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + // The inline path must never be taken in TRANSITIVE mode, even on a full sync. + Mockito.verify(graphClient, Mockito.never()).getGroupDeltaWithMembers(Mockito.any()); + // Membership must be resolved per-group (TRANSITIVE => /transitiveMembers). + Mockito.verify(graphClient).getGroupMembers(GROUP_GUID, MembershipMode.TRANSITIVE); + } + + @Test + public void test25_removedGroupInInlinePage_excludedFromUpsertAndMembership() throws Throwable { + // A group flagged @removed in an inline getGroupDeltaWithMembers page must be routed + // to the delete map ONLY: not surfaced in the upsert map or the membership map + // (no stale group record, no orphaned membership edges). + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(true); + Mockito.when(ugSyncConfig.getUserSyncDeletesFrequency()).thenReturn(100L); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta", new DeltaEntry<>(user(), false))); + String removedGroupGuid = "cccccccc-0000-0000-0000-000000000009"; + + // can carry a DIFFERENT guid than the helper's fixed GROUP_GUID. + GraphGroup liveGroup = group(GROUP_GUID, GROUP_NAME); + GraphGroup removedGroup = new GraphGroup(); + removedGroup.setId(removedGroupGuid); + removedGroup.setDisplayName("OldTeam"); + + // Membership map carries entries for BOTH groups, to prove the removed group's + // membership is not surfaced even when present in the page's map. + Map> membership = new HashMap<>(); + membership.put(GROUP_GUID, new LinkedHashSet<>(List.of(USER_GUID))); + membership.put(removedGroupGuid, new LinkedHashSet<>(List.of(USER_GUID))); + + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage(membership, + new DeltaEntry<>(liveGroup, false), new DeltaEntry<>(removedGroup, true))); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + + // 1. Removed group -> delete map, keyed by GUID. + ArgumentCaptor>> delGroups = ArgumentCaptor.forClass(Map.class); + Mockito.verify(sink).deleteUsersAndGroups(Mockito.any(), delGroups.capture()); + Assertions.assertTrue(delGroups.getValue().containsKey(removedGroupGuid), "removed group must be in the delete map"); + + // 2. Removed group NOT in upsert or membership maps; live group IS upserted. + ArgumentCaptor>> groupUpserts = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor>> groupMembers = ArgumentCaptor.forClass(Map.class); + Mockito.verify(sink).addOrUpdateUsersGroups(groupUpserts.capture(), Mockito.any(), groupMembers.capture(), Mockito.anyBoolean()); + Assertions.assertFalse(groupUpserts.getValue().containsKey(removedGroupGuid), "removed group must not be upserted"); + Assertions.assertTrue(groupUpserts.getValue().containsKey(GROUP_GUID), "live group must be upserted"); + Assertions.assertFalse(groupMembers.getValue().containsKey(removedGroupGuid), "removed group must not have membership edges surfaced"); + } + + @Test + public void test26_transitiveGroupMemberFetch404_skipsGroupNotWholeSync() throws Throwable { + // TRANSITIVE mode: one group 404s on member fetch. That group's membership is skipped, + // but the sync completes and OTHER groups still sync. + Mockito.when(graphConfig.getMembershipMode()).thenReturn(MembershipMode.TRANSITIVE); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uD", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gD", new DeltaEntry<>(group(GROUP_GUID, "GoodGroup"), false), + new DeltaEntry<>(group("bad-guid", "GoneGroup"), false))); + // Good group returns members; bad group 404s. + Mockito.when(graphClient.getGroupMembers(GROUP_GUID, MembershipMode.TRANSITIVE)).thenReturn(List.of(userRef(USER_GUID))); + Mockito.when(graphClient.getGroupMembers("bad-guid", MembershipMode.TRANSITIVE)).thenThrow(new GraphClientException("not found", 404)); + + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // must NOT throw + + // Both groups upserted; good group has its member, bad group has empty membership. + Map>[] maps = captureUserAndGroupMaps(); + Assertions.assertTrue(maps[0].containsKey(GROUP_GUID), "good group synced"); + Assertions.assertTrue(maps[0].containsKey("bad-guid"), "bad group still synced (membership skipped)"); + } + + @Test + public void test27_transitiveGroupMemberFetch401_failsWholeCycle() throws Throwable { + // A systemic failure (401, not 404) during member fetch must propagate and fail the + // cycle -- we do NOT silently sync empty memberships when the connection is broken. + Mockito.when(graphConfig.getMembershipMode()).thenReturn(MembershipMode.TRANSITIVE); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uD", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gD", new DeltaEntry<>(group(GROUP_GUID, "G"), false))); + Mockito.when(graphClient.getGroupMembers(GROUP_GUID, MembershipMode.TRANSITIVE)).thenThrow(new GraphClientException("unauthorized", 401)); + + EntraIdUserGroupSource source = newSource(); + Assertions.assertThrows(GraphClientException.class, () -> source.updateSink(sink)); + } + + @Test + public void test28_nullUserPage_treatedAsEmptyNoNpe() throws Throwable { + // A null page from the client (contract violation / empty-body parse) must degrade to + // an empty sync, not NPE. Token must NOT advance (null page = nothing received). + Mockito.when(graphClient.getUserDelta(null)).thenReturn(null); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage(new HashMap<>())); // empty groups + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // must NOT throw + // No users upserted; sink still called with empty maps. + Map>[] maps = captureUserAndGroupMaps(); + Assertions.assertTrue(maps[1].isEmpty(), "null user page yields no users"); + } + + @Test + public void test29_emptyPageWithDeltaLink_advancesToken() throws Throwable { + // An empty value array with a valid deltaLink must advance the token, so the next + // cycle resumes from the new token rather than re-fetching the empty page forever. + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("USER-TOKEN-2")); // no entries + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage(new HashMap<>())); // no groups, deltaLink "gDelta" + + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + + // Second cycle should resume from the advanced token, not null. + Mockito.when(graphConfig.getMembershipMode()).thenReturn(MembershipMode.DIRECT); + // firstSyncDone is now true, so the next getUserDelta is called with the new token: + Mockito.when(graphClient.getUserDelta("USER-TOKEN-2")).thenReturn(userPage("USER-TOKEN-3")); + Mockito.when(graphClient.getGroupDelta("gDelta")).thenReturn(groupPage("gDelta2")); + source.updateSink(sink); + + // Verify the second cycle used the advanced token, proving it was persisted. + Mockito.verify(graphClient).getUserDelta("USER-TOKEN-2"); + } + + @Test + public void test30_emptyDisplayName_groupExcludedLikeNull() throws Throwable { + // group("") must be treated the same as group(null): StringUtils.isBlank catches both, + // so an empty-name group is not synced (an empty Ranger group name would be invalid). + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uD", new DeltaEntry<>(user(), false))); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage(new HashMap<>(), new DeltaEntry<>(group(GROUP_GUID, ""), false))); // empty display name + + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + + Map>[] maps = captureUserAndGroupMaps(); + Assertions.assertTrue(maps[0].isEmpty(), "group with empty displayName must be excluded"); + } + + @Test + public void test12_deltaTokens_advanceAfterSuccessfulSink() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(false); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-1")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta-1")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // first cycle: full pull (null), captures uDelta-1/gDelta-1 + // Second cycle should now use the persisted tokens, not null. + Mockito.when(graphClient.getUserDelta("uDelta-1")).thenReturn(userPage("uDelta-2")); + Mockito.when(graphClient.getGroupDelta("gDelta-1")).thenReturn(groupPage("gDelta-2")); + source.updateSink(sink); + Mockito.verify(graphClient).getUserDelta("uDelta-1"); + Mockito.verify(graphClient).getGroupDelta("gDelta-1"); + } + + @Test + public void test13_deltaTokens_doNotAdvanceWhenSinkFails() throws Throwable { + Mockito.when(ugSyncConfig.isUserSyncDeletesEnabled()).thenReturn(false); + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta-1")); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta-1")); + Mockito.doThrow(new RuntimeException("admin down")).when(sink).addOrUpdateUsersGroups(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyBoolean()); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); // sink throws; tokens must not advance + source.updateSink(sink); // second cycle must retry with null, not uDelta-1 + // getUserDelta(null) called on both cycles (token never advanced). + Mockito.verify(graphClient, Mockito.times(2)).getUserDelta(null); + Mockito.verify(graphClient, Mockito.never()).getUserDelta("uDelta-1"); + } + + @Test + public void test14_groupWithoutDisplayName_isSkipped() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDeltaWithMembers(null)).thenReturn(membersPage("gDelta", new DeltaEntry<>(group(GROUP_GUID, null), false))); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Map> groups = captureUserAndGroupMaps()[0]; + Assertions.assertTrue(groups.isEmpty(), "a group with no display name has no Ranger name and is skipped"); + } + + @Test + public void test15_auditInfo_isPosted() throws Throwable { + Mockito.when(graphClient.getUserDelta(null)).thenReturn(userPage("uDelta")); + Mockito.when(graphClient.getGroupDelta(null)).thenReturn(groupPage("gDelta")); + EntraIdUserGroupSource source = newSource(); + source.updateSink(sink); + Mockito.verify(sink).postUserGroupAuditInfo(Mockito.any()); + } +} diff --git a/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestCertificateTokenProvider.java b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestCertificateTokenProvider.java new file mode 100644 index 00000000000..6b15f34f6ff --- /dev/null +++ b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestCertificateTokenProvider.java @@ -0,0 +1,218 @@ +/* + * 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.ranger.entraid.graph; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig.AuthMode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import javax.ws.rs.core.Form; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; + +public class TestCertificateTokenProvider { + private static final String KEYSTORE_PATH = "src/test/resources/entraid-test.p12"; + private static final String KEYSTORE_PASSWORD = "changeit"; + private static final String CERT_ALIAS = "entraid-test"; + private static final String TENANT = "11111111-1111-1111-1111-111111111111"; + private static final String CLIENT = "22222222-2222-2222-2222-222222222222"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static X509Certificate certificate; + + @BeforeAll + public static void loadKeystore() throws Exception { + try (InputStream in = Files.newInputStream(Paths.get(KEYSTORE_PATH))) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(in, KEYSTORE_PASSWORD.toCharArray()); + certificate = (X509Certificate) ks.getCertificate(CERT_ALIAS); + } + } + + private EntraIdGraphConfig certConfig() { + return new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath(KEYSTORE_PATH) + .certAlias(CERT_ALIAS) + .keystorePassword(KEYSTORE_PASSWORD.toCharArray()) + .authorityHost("https://login.microsoftonline.com") + .build(); + } + + private String buildAssertion() throws Exception { + CertificateTokenProvider provider = new CertificateTokenProvider(); + provider.init(certConfig(), null); + Form form = new Form(); + provider.addCredential(form); + List values = form.asMap().get("client_assertion"); + return values.get(0); + } + + private static String b64UrlDecodeToString(String segment) { + return new String(Base64.getUrlDecoder().decode(segment), StandardCharsets.UTF_8); + } + + @Test + public void test01_init_loadsKeystoreWithoutError() { + Assertions.assertDoesNotThrow(() -> { + CertificateTokenProvider provider = new CertificateTokenProvider(); + provider.init(certConfig(), null); + }); + } + + @Test + public void test02_addCredential_setsAssertionType() throws Exception { + CertificateTokenProvider provider = new CertificateTokenProvider(); + provider.init(certConfig(), null); + Form form = new Form(); + provider.addCredential(form); + Assertions.assertEquals("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", form.asMap().get("client_assertion_type").get(0)); + } + + @Test + public void test03_assertion_hasThreeSegments() throws Exception { + String assertion = buildAssertion(); + Assertions.assertEquals(3, assertion.split("\\.").length); + } + + @Test + public void test04_header_algIsRs256() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode header = MAPPER.readTree(b64UrlDecodeToString(parts[0])); + Assertions.assertEquals("RS256", header.get("alg").asText()); + Assertions.assertEquals("JWT", header.get("typ").asText()); + } + + @Test + public void test05_header_x5tMatchesCertSha1Thumbprint() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode header = MAPPER.readTree(b64UrlDecodeToString(parts[0])); + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(sha1.digest(certificate.getEncoded())); + Assertions.assertEquals(expected, header.get("x5t").asText()); + } + + @Test + public void test06_claims_audIsTokenEndpoint() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode claims = MAPPER.readTree(b64UrlDecodeToString(parts[1])); + Assertions.assertEquals("https://login.microsoftonline.com/" + TENANT + "/oauth2/v2.0/token", claims.get("aud").asText()); + } + + @Test + public void test07_claims_issAndSubAreClientId() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode claims = MAPPER.readTree(b64UrlDecodeToString(parts[1])); + Assertions.assertEquals(CLIENT, claims.get("iss").asText()); + Assertions.assertEquals(CLIENT, claims.get("sub").asText()); + } + + @Test + public void test08_claims_expIsAfterNbf() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode claims = MAPPER.readTree(b64UrlDecodeToString(parts[1])); + Assertions.assertTrue(claims.get("exp").asLong() > claims.get("nbf").asLong()); + } + + @Test + public void test09_claims_jtiIsPresent() throws Exception { + String[] parts = buildAssertion().split("\\."); + JsonNode claims = MAPPER.readTree(b64UrlDecodeToString(parts[1])); + Assertions.assertTrue(claims.hasNonNull("jti")); + Assertions.assertFalse(claims.get("jti").asText().isEmpty()); + } + + @Test + public void test10_signature_verifiesAgainstCertPublicKey() throws Exception { + String assertion = buildAssertion(); + String[] parts = assertion.split("\\."); + String signingInput = parts[0] + "." + parts[1]; + byte[] sigBytes = Base64.getUrlDecoder().decode(parts[2]); + PublicKey publicKey = certificate.getPublicKey(); + Signature verifier = Signature.getInstance("SHA256withRSA"); + verifier.initVerify(publicKey); + verifier.update(signingInput.getBytes(StandardCharsets.UTF_8)); + Assertions.assertTrue(verifier.verify(sigBytes), "client_assertion signature must verify against the certificate's public key"); + } + + @Test + public void test11_eachAssertion_hasUniqueJti() throws Exception { + JsonNode c1 = MAPPER.readTree(b64UrlDecodeToString(buildAssertion().split("\\.")[1])); + JsonNode c2 = MAPPER.readTree(b64UrlDecodeToString(buildAssertion().split("\\.")[1])); + Assertions.assertNotEquals(c1.get("jti").asText(), c2.get("jti").asText()); + } + + @Test + public void test12_wrongAlias_throws() { + EntraIdGraphConfig badAlias = new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath(KEYSTORE_PATH) + .certAlias("no-such-alias") + .keystorePassword(KEYSTORE_PASSWORD.toCharArray()) + .build(); + CertificateTokenProvider provider = new CertificateTokenProvider(); + Assertions.assertThrows(GraphClientException.class, () -> provider.init(badAlias, null)); + } + + @Test + public void test13_wrongKeystorePassword_throws() { + EntraIdGraphConfig badPwd = new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath(KEYSTORE_PATH) + .certAlias(CERT_ALIAS) + .keystorePassword("wrong-password".toCharArray()) + .build(); + CertificateTokenProvider provider = new CertificateTokenProvider(); + Assertions.assertThrows(GraphClientException.class, () -> provider.init(badPwd, null)); + } + + @Test + public void test14_missingKeystoreFile_throwsWrappedException() { + EntraIdGraphConfig badPath = new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath("src/test/resources/non-existent.p12") + .certAlias(CERT_ALIAS) + .keystorePassword(KEYSTORE_PASSWORD.toCharArray()) + .build(); + CertificateTokenProvider provider = new CertificateTokenProvider(); + Assertions.assertThrows(GraphClientException.class, () -> provider.init(badPath, null)); + } +} diff --git a/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphClientImpl.java b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphClientImpl.java new file mode 100644 index 00000000000..c5e4c9fec84 --- /dev/null +++ b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphClientImpl.java @@ -0,0 +1,568 @@ +/* + * 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.ranger.entraid.graph; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig.AuthMode; +import org.apache.ranger.ugsyncutil.model.graph.DeltaEntry; +import org.apache.ranger.ugsyncutil.model.graph.DeltaPage; +import org.apache.ranger.ugsyncutil.model.graph.GraphGroup; +import org.apache.ranger.ugsyncutil.model.graph.GraphMemberRef; +import org.apache.ranger.ugsyncutil.model.graph.GraphUser; +import org.apache.ranger.ugsyncutil.model.graph.GroupMembershipPage; +import org.apache.ranger.ugsyncutil.model.graph.MemberType; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +@ExtendWith(MockitoExtension.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +public class TestEntraIdGraphClientImpl { + private static final String TENANT = "11111111-1111-1111-1111-111111111111"; + private static final String CLIENT = "22222222-2222-2222-2222-222222222222"; + private static final String RESOURCE_DIR = "src/test/resources/"; + private static final String BASE_URL_TAG = "__BASE_URL__"; + + private HttpServer server; + private String baseUrl; + + @BeforeEach + public void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + server.createContext("/" + TENANT + "/oauth2/v2.0/token", exchange -> + respond(exchange, 200, "{\"access_token\":\"test-token\",\"expires_in\":3600}")); + server.start(); + } + + @AfterEach + public void stopServer() { + if (server != null) { + server.stop(0); + } + } + + private EntraIdGraphConfig stubConfig() { + return new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CLIENT_SECRET) + .clientSecret("test-secret".toCharArray()) + .authorityHost(baseUrl) + .graphBaseUrl(baseUrl) + .retryBaseBackoffMs(5) + .maxRetries(3) + .build(); + } + + private EntraIdGraphClientImpl newClient() throws Exception { + EntraIdGraphClientImpl client = new EntraIdGraphClientImpl(); + client.init(stubConfig()); + return client; + } + + private void context(String path, HttpHandler handler) { + server.createContext(path, handler); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + @Test + public void test01_getUserDelta_mapsCoreUserFields() throws Exception { + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"alice@example.com\"," + + "\"mail\":\"alice@example.com\",\"displayName\":\"Alice\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=D1\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(1, page.getEntries().size()); + GraphUser user = page.getEntries().get(0).getValue(); + Assertions.assertEquals("u-1", user.getId()); + Assertions.assertEquals("alice@example.com", user.getUserPrincipalName()); + Assertions.assertEquals("Alice", user.getDisplayName()); + } + } + + @Test + public void test02_getUserDelta_capturesDeltaLink() throws Exception { + String deltaLink = baseUrl + "/v1.0/users/delta?$deltatoken=ABC"; + context("/v1.0/users/delta", exchange -> respond(exchange, 200, "{\"value\":[],\"@odata.deltaLink\":\"" + deltaLink + "\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(deltaLink, page.getDeltaLink()); + } + } + + @Test + public void test03_getUserDelta_followsNextLinkAndAggregates() throws Exception { + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"a@x\"}]," + + "\"@odata.nextLink\":\"" + baseUrl + "/v1.0/users/delta/page2\"}")); + context("/v1.0/users/delta/page2", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-2\",\"userPrincipalName\":\"b@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=END\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(2, page.getEntries().size()); + Assertions.assertTrue(page.getDeltaLink().contains("$deltatoken=END")); + } + } + + @Test + public void test04_getUserDelta_marksRemovedEntries() throws Exception { + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-9\",\"@removed\":{\"reason\":\"deleted\"}}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=D\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertTrue(page.getEntries().get(0).isRemoved()); + } + } + + @Test + public void test05_getUserDelta_usesProvidedDeltaLinkVerbatim() throws Exception { + context("/v1.0/users/delta/resume", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-r\",\"userPrincipalName\":\"r@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=D2\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(baseUrl + "/v1.0/users/delta/resume"); + Assertions.assertEquals("u-r", page.getEntries().get(0).getValue().getId()); + } + } + + @Test + public void test06_throttling_retriesAfter429ThenSucceeds() throws Exception { + AtomicInteger hits = new AtomicInteger(); + context("/v1.0/users/delta", exchange -> { + if (hits.getAndIncrement() == 0) { + respond(exchange, 429, "{\"error\":\"throttled\"}"); + } else { + respond(exchange, 200, "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"a@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=D\"}"); + } + }); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(1, page.getEntries().size()); + Assertions.assertEquals(2, hits.get(), "client should have retried exactly once after the 429"); + } + } + + @Test + public void test07_throttling_exhaustsRetriesThenThrows() throws Exception { + context("/v1.0/users/delta", exchange -> respond(exchange, 429, "{\"error\":\"throttled\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + GraphClientException ex = Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + Assertions.assertEquals(429, ex.getHttpStatus()); + } + } + + @Test + public void test08_nonRetryableError_throwsWithStatus() throws Exception { + context("/v1.0/users/delta", exchange -> respond(exchange, 403, "{\"error\":\"forbidden\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + GraphClientException ex = Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + Assertions.assertEquals(403, ex.getHttpStatus()); + } + } + + @Test + public void test09_getGroupMembers_directUsesTypedUserSegment() throws Exception { + // Client casts to microsoft.graph.user; Graph returns only users (no @odata.type), + // and every result is mapped as USER by construction. + context("/v1.0/groups/g-1/members/microsoft.graph.user", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\"},{\"id\":\"u-2\"}]}")); + try (EntraIdGraphClientImpl client = newClient()) { + List members = client.getGroupMembers("g-1", MembershipMode.DIRECT); + Assertions.assertEquals(2, members.size()); + Assertions.assertEquals(MemberType.USER, members.get(0).getType()); + Assertions.assertEquals(MemberType.USER, members.get(1).getType()); + } + } + + @Test + public void test10_getGroupMembers_transitiveUsesTransitiveEndpoint() throws Exception { + context("/v1.0/groups/g-1/transitiveMembers/microsoft.graph.user", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"@odata.type\":\"#microsoft.graph.user\"}]}")); + try (EntraIdGraphClientImpl client = newClient()) { + List members = client.getGroupMembers("g-1", MembershipMode.TRANSITIVE); + Assertions.assertEquals(1, members.size()); + Assertions.assertEquals("u-1", members.get(0).getId()); + } + } + + @Test + public void test11_getGroupDelta_mapsGroup() throws Exception { + context("/v1.0/groups/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"g-1\",\"displayName\":\"Engineering\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/groups/delta?$deltatoken=G1\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getGroupDelta(null); + Assertions.assertEquals(1, page.getEntries().size()); + Assertions.assertEquals("Engineering", page.getEntries().get(0).getValue().getDisplayName()); + } + } + + @Test + public void test12_init_failsFastWhenTokenEndpointErrors() throws Exception { + server.removeContext("/" + TENANT + "/oauth2/v2.0/token"); + context("/" + TENANT + "/oauth2/v2.0/token", exchange -> respond(exchange, 401, "{\"error\":\"invalid_client\"}")); + EntraIdGraphClientImpl client = new EntraIdGraphClientImpl(); + Assertions.assertThrows(GraphClientException.class, () -> client.init(stubConfig())); + client.close(); + } + + @Test + public void test13_userDelta_mapsAllEntries() throws Exception { + serve("/v1.0/users/delta", "users-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + // 3 users + 1 @removed entry in the loadGraphResponse. + Assertions.assertEquals(4, page.getEntries().size()); + } + } + + @Test + public void test14_userDelta_parsesFieldsAndNullMail() throws Exception { + serve("/v1.0/users/delta", "users-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + GraphUser alice = page.getEntries().get(0).getValue(); + Assertions.assertEquals("11111111-aaaa-bbbb-cccc-000000000001", alice.getId()); + Assertions.assertEquals("Alice Anderson", alice.getDisplayName()); + GraphUser carol = page.getEntries().get(2).getValue(); + Assertions.assertNull(carol.getMail()); + } + } + + @Test + public void test15_userDelta_flagsRemovedEntry() throws Exception { + serve("/v1.0/users/delta", "users-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + DeltaEntry last = page.getEntries().get(3); + Assertions.assertTrue(last.isRemoved(), "the @removed entry must be flagged removed"); + } + } + + @Test + public void test16_userDelta_capturesDeltaToken() throws Exception { + serve("/v1.0/users/delta", "users-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertTrue(page.getDeltaLink().contains("SAMPLE_USER_DELTA_TOKEN")); + } + } + + @Test + public void test17_groupDelta_mapsGroups() throws Exception { + serve("/v1.0/groups/delta", "groups-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getGroupDelta(null); + Assertions.assertEquals(3, page.getEntries().size()); + Assertions.assertEquals("Engineering", page.getEntries().get(0).getValue().getDisplayName()); + } + } + + @Test + public void test18_groupDelta_flagsRemovedGroup() throws Exception { + serve("/v1.0/groups/delta", "groups-delta-page.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getGroupDelta(null); + Assertions.assertTrue(page.getEntries().get(2).isRemoved()); + } + } + + @Test + public void test19_members_mapsUsers() throws Exception { + serve("/v1.0/groups/22222222-aaaa-bbbb-cccc-000000000001/members/microsoft.graph.user", "group-members.json"); + try (EntraIdGraphClientImpl client = newClient()) { + List members = client.getGroupMembers("22222222-aaaa-bbbb-cccc-000000000001", MembershipMode.DIRECT); + Assertions.assertEquals(2, members.size()); + Assertions.assertEquals(MemberType.USER, members.get(0).getType()); + Assertions.assertEquals(MemberType.USER, members.get(1).getType()); + } + } + + @Test + public void test20_paged_followNextLinkAndAggregate() throws Exception { + serve("/v1.0/users/delta", "users-delta-page1.json"); + serve("/v1.0/users/delta/page2", "users-delta-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + // 2 users on page 1 + 1 on page 2 = 3 aggregated. + Assertions.assertEquals(3, page.getEntries().size()); + Assertions.assertTrue(page.getDeltaLink().contains("PAGED_SAMPLE_TOKEN")); + } + } + + @Test + public void test21_tokenExpired_intercepts401AndRefreshesToken() throws Exception { + AtomicInteger hits = new AtomicInteger(); + context("/v1.0/users/delta", exchange -> { + if (hits.getAndIncrement() == 0) { + respond(exchange, 401, "{\"error\":{\"code\":\"InvalidAuthenticationToken\"}}"); + } else { + respond(exchange, 200, "{\"value\":[{\"id\":\"u-refreshed\"}],\"@odata.deltaLink\":\"done\"}"); + } + }); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(1, page.getEntries().size()); + Assertions.assertEquals("u-refreshed", page.getEntries().get(0).getValue().getId()); + Assertions.assertEquals(2, hits.get()); + } + } + + @Test + public void test22_getGroupMembers_followsNextLinkAndAggregates() throws Exception { + context("/v1.0/groups/g-paged/members/microsoft.graph.user", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-p1\"}],\"@odata.nextLink\":\"" + baseUrl + "/v1.0/groups/g-paged/members/page2\"}")); + context("/v1.0/groups/g-paged/members/page2", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-p2\"}]}")); + try (EntraIdGraphClientImpl client = newClient()) { + List members = client.getGroupMembers("g-paged", MembershipMode.DIRECT); + Assertions.assertEquals(2, members.size()); + Assertions.assertEquals("u-p1", members.get(0).getId()); + Assertions.assertEquals("u-p2", members.get(1).getId()); + } + } + + @Test + public void test23_userDelta_410Gone_resyncsFromBaseAndFlagsResynced() throws Exception { + // Expired delta link returns 410; the base delta URL then returns a normal page. + context("/v1.0/users/delta/expired", exchange -> + respond(exchange, 410, "{\"error\":{\"code\":\"resyncRequired\"}}")); + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"a@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=NEW\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(baseUrl + "/v1.0/users/delta/expired"); + Assertions.assertTrue(page.isResynced(), "a 410-triggered full pull must be flagged resynced"); + Assertions.assertEquals(1, page.getEntries().size()); + Assertions.assertTrue(page.getDeltaLink().contains("NEW"), "must capture the fresh delta token"); + } + } + + @Test + public void test24_userDelta_400SyncStateNotFound_resyncs() throws Exception { + context("/v1.0/users/delta/expired", exchange -> + respond(exchange, 400, "{\"error\":{\"code\":\"syncStateNotFound\",\"message\":\"token expired\"}}")); + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"a@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=NEW\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(baseUrl + "/v1.0/users/delta/expired"); + Assertions.assertTrue(page.isResynced(), "400 syncStateNotFound must trigger a flagged resync"); + Assertions.assertEquals(1, page.getEntries().size()); + } + } + + @Test + public void test25_groupDelta_410Gone_resyncs() throws Exception { + context("/v1.0/groups/delta/expired", exchange -> + respond(exchange, 410, "{\"error\":{\"code\":\"resyncRequired\"}}")); + context("/v1.0/groups/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"g-1\",\"displayName\":\"Engineering\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/groups/delta?$deltatoken=NEW\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getGroupDelta(baseUrl + "/v1.0/groups/delta/expired"); + Assertions.assertTrue(page.isResynced()); + Assertions.assertEquals(1, page.getEntries().size()); + } + } + + @Test + public void test26_userDelta_resyncOnFullPull_propagatesException() throws Exception { + // If the *base* delta URL itself returns expiry (deltaLink == null path), there is + // nothing to fall back to -> the resync exception must propagate, not loop. + context("/v1.0/users/delta", exchange -> respond(exchange, 410, "{\"error\":{\"code\":\"resyncRequired\"}}")); + try (EntraIdGraphClientImpl client = newClient()) { + Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + } + } + + @Test + public void test27_userDelta_normalPage_isNotFlaggedResynced() throws Exception { + // A normal (non-expired) delta must NOT be flagged resynced. + context("/v1.0/users/delta", exchange -> respond(exchange, 200, + "{\"value\":[{\"id\":\"u-1\",\"userPrincipalName\":\"a@x\"}]," + + "\"@odata.deltaLink\":\"" + baseUrl + "/v1.0/users/delta?$deltatoken=D\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertFalse(page.isResynced(), "a normal delta page must not be flagged resynced"); + } + } + + @Test + public void test28_membersMerge_accumulatesSameGroupAcrossPages() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + // G1 members are split: u1,u2 on page1 and u4,u5 on page2 -> merged set of 4. + Set g1 = page.getMembers("G1"); + Assertions.assertEquals(4, g1.size(), "G1 membership must merge both pages"); + Assertions.assertTrue(g1.containsAll(java.util.Arrays.asList("u1", "u2", "u4", "u5"))); + } + } + + @Test + public void test29_membersMerge_excludesGroupTypeMembers() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + // page1 G1 contains a #microsoft.graph.group member (nestedGroup1) which must be dropped. + Assertions.assertFalse(page.getMembers("G1").contains("nestedGroup1"), "group-type members must be excluded in DIRECT mode"); + } + } + + @Test + public void test30_membersMerge_dedupsGroupObjectAcrossPages() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + // G1, G2, G3 = three distinct groups even though G1 appeared on two pages. + long g1Count = page.getGroups().stream().filter(e -> "G1".equals(e.getValue().getId())).count(); + Assertions.assertEquals(1, g1Count, "G1 must appear once despite spanning two pages"); + Assertions.assertEquals(3, page.getGroups().size(), "G1, G2, G3 expected"); + } + } + + @Test + public void test31_membersMerge_groupWithNoMembersHasEmptySet() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + // G3 has no members@delta property at all -> empty set, not null. + Assertions.assertTrue(page.getMembers("G3").isEmpty(), "a memberless group yields an empty set"); + } + } + + @Test + public void test32_membersMerge_secondGroupMembersIntact() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + Set g2 = page.getMembers("G2"); + Assertions.assertEquals(1, g2.size()); + Assertions.assertTrue(g2.contains("u3")); + } + } + + @Test + public void test33_membersMerge_capturesDeltaLink() throws Exception { + serve("/v1.0/groups/delta", "groups-members-page1.json"); + serve("/v1.0/groups/delta/page2", "groups-members-page2.json"); + try (EntraIdGraphClientImpl client = newClient()) { + GroupMembershipPage page = client.getGroupDeltaWithMembers(null); + Assertions.assertTrue(page.getDeltaLink().contains("MEMBERS_TOKEN"), "the deltaLink from the final page must be captured"); + } + } + + @Test + public void test34_malformedJsonOn200_throwsGraphClientException() throws Exception { + // A 200 with a truncated/unparseable body must surface as GraphClientException, + // never a silently-empty page. + context("/v1.0/users/delta", exchange -> respond(exchange, 200, "{ \"value\": [ ")); // truncated JSON + try (EntraIdGraphClientImpl client = newClient()) { + Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + } + } + + @Test + public void test35_doubleUnauthorized_refreshesOnceThenThrows() throws Exception { + // First call 401 -> client force-refreshes the token and retries once. If the retry + // ALSO 401s, the client must throw (not loop forever refreshing a bad token). + AtomicInteger calls = new AtomicInteger(); + context("/v1.0/users/delta", exchange -> { + calls.incrementAndGet(); + respond(exchange, 401, "{\"error\":{\"code\":\"InvalidAuthenticationToken\"}}"); + }); + try (EntraIdGraphClientImpl client = newClient()) { + Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + // Exactly two attempts: the original + one post-refresh retry. No infinite loop. + Assertions.assertEquals(2, calls.get(), "client must retry once after refresh, then stop"); + } + } + + @Test + public void test36_missingDeltaLink_yieldsNullTokenNotCrash() throws Exception { + // A successful page that omits @odata.deltaLink (and @odata.nextLink) must return a + // page with a null delta link -- gracefully, without throwing. (The source treats a + // null token as "full pull next cycle".) + context("/v1.0/users/delta", exchange -> respond(exchange, 200, "{\"value\":[{\"id\":\"u1\",\"userPrincipalName\":\"a@example.com\"}]}")); + try (EntraIdGraphClientImpl client = newClient()) { + DeltaPage page = client.getUserDelta(null); + Assertions.assertEquals(1, page.getEntries().size(), "the user on the page must still be returned"); + Assertions.assertNull(page.getDeltaLink(), "missing deltaLink must yield a null token, not a crash"); + } + } + + @Test + public void test37_nextLinkToForeignHost_isRefused() throws Exception { + // SSRF/defense-in-depth: a @odata.nextLink pointing off the configured Graph host must + // NOT be followed (would leak the bearer token to an attacker-influenced host). The + // client must throw rather than fetch it. + context("/v1.0/users/delta", exchange -> respond(exchange, 200, "{\"value\":[{\"id\":\"u1\"}]," + "\"@odata.nextLink\":\"http://169.254.169.254/latest/meta-data/\"}")); + try (EntraIdGraphClientImpl client = newClient()) { + GraphClientException ex = Assertions.assertThrows(GraphClientException.class, () -> client.getUserDelta(null)); + Assertions.assertTrue(ex.getMessage().toLowerCase().contains("host") || ex.getMessage().toLowerCase().contains("refus"), "error should indicate the URL was outside the configured Graph host"); + } + } + + private String loadGraphResponse(String fileName) throws IOException { + String content = Files.readString(Paths.get(RESOURCE_DIR + fileName)); + return content.replace(BASE_URL_TAG, baseUrl); + } + + private void serve(String path, String fileName) { + context(path, exchange -> respond(exchange, 200, loadGraphResponse(fileName))); + } +} diff --git a/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfig.java b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfig.java new file mode 100644 index 00000000000..f03f8b75758 --- /dev/null +++ b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfig.java @@ -0,0 +1,200 @@ +/* + * 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.ranger.entraid.graph; + +import org.apache.ranger.entraid.graph.EntraIdGraphConfig.AuthMode; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +public class TestEntraIdGraphConfig { + private static final String TENANT = "11111111-1111-1111-1111-111111111111"; + private static final String CLIENT = "22222222-2222-2222-2222-222222222222"; + + /** A builder pre-filled with the minimum valid CERTIFICATE configuration. */ + private EntraIdGraphConfig.Builder validCertBuilder() { + return new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath("/etc/ranger/usersync/entraid.p12") + .certAlias("entraid") + .keystorePassword("secret".toCharArray()); + } + + /** A builder pre-filled with the minimum valid CLIENT_SECRET configuration. */ + private EntraIdGraphConfig.Builder validSecretBuilder() { + return new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CLIENT_SECRET) + .clientSecret("app-secret".toCharArray()); + } + + @Test + public void test01_build_minimalCertificate_succeeds() { + EntraIdGraphConfig config = validCertBuilder().build(); + Assertions.assertEquals(TENANT, config.getTenantId()); + Assertions.assertEquals(CLIENT, config.getClientId()); + Assertions.assertEquals(AuthMode.CERTIFICATE, config.getAuthMode()); + } + + @Test + public void test02_build_minimalClientSecret_succeeds() { + EntraIdGraphConfig config = validSecretBuilder().build(); + Assertions.assertEquals(AuthMode.CLIENT_SECRET, config.getAuthMode()); + } + + @Test + public void test03_build_missingTenantId_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().tenantId(null); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test04_build_blankTenantId_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().tenantId(" "); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test05_build_missingClientId_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().clientId(null); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test06_build_missingAuthMode_throws() { + EntraIdGraphConfig.Builder b = new EntraIdGraphConfig.Builder().tenantId(TENANT).clientId(CLIENT); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test07_certificate_missingKeystorePath_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().keystorePath(null); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test08_certificate_missingCertAlias_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().certAlias(null); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test09_certificate_withClientSecret_throws() { + // Mixing a client secret into certificate auth is a misconfiguration. + EntraIdGraphConfig.Builder b = validCertBuilder().clientSecret("oops".toCharArray()); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test10_clientSecret_missingSecret_throws() { + EntraIdGraphConfig.Builder b = new EntraIdGraphConfig.Builder().tenantId(TENANT).clientId(CLIENT).authMode(AuthMode.CLIENT_SECRET); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test11_clientSecret_withKeystorePath_throws() { + EntraIdGraphConfig.Builder b = validSecretBuilder().keystorePath("/etc/ranger/x.p12"); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test12_build_pageSizeBelowOne_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().pageSize(0); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test13_build_negativeMaxRetries_throws() { + EntraIdGraphConfig.Builder b = validCertBuilder().maxRetries(-1); + Assertions.assertThrows(IllegalStateException.class, b::build); + } + + @Test + public void test14_tokenEndpoint_derivedFromAuthorityAndTenant() { + EntraIdGraphConfig config = validCertBuilder().authorityHost("https://login.microsoftonline.com").build(); + Assertions.assertEquals("https://login.microsoftonline.com/" + TENANT + "/oauth2/v2.0/token", config.getTokenEndpoint()); + } + + @Test + public void test15_tokenEndpoint_honorsSovereignAuthorityHost() { + EntraIdGraphConfig config = validCertBuilder().authorityHost("https://login.partner.microsoftonline.cn").build(); + Assertions.assertEquals("https://login.partner.microsoftonline.cn/" + TENANT + "/oauth2/v2.0/token", config.getTokenEndpoint()); + } + + @Test + public void test16_defaultScope_derivedFromGraphBaseUrl() { + EntraIdGraphConfig config = validCertBuilder().graphBaseUrl("https://graph.microsoft.com").build(); + Assertions.assertEquals("https://graph.microsoft.com/.default", config.getDefaultScope()); + } + + @Test + public void test17_defaultScope_matchesSovereignGraphHost() { + EntraIdGraphConfig config = validCertBuilder().graphBaseUrl("https://microsoftgraph.indiacloudapi.in").build(); + Assertions.assertEquals("https://microsoftgraph.indiacloudapi.in/.default", config.getDefaultScope()); + } + + @Test + public void test18_defaults_areApplied() { + EntraIdGraphConfig config = validCertBuilder().build(); + Assertions.assertEquals("https://login.microsoftonline.com", config.getAuthorityHost()); + Assertions.assertEquals(MembershipMode.DIRECT, config.getMembershipMode()); + } + + @Test + public void test19_keystorePassword_isDefensivelyCopiedOnBuild() { + char[] pwd = "secret".toCharArray(); + EntraIdGraphConfig config = new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CERTIFICATE) + .keystorePath("/etc/ranger/usersync/entraid.p12") + .certAlias("entraid") + .keystorePassword(pwd) + .build(); + // Mutating the caller's array must not affect the stored credential. + Arrays.fill(pwd, '\0'); + Assertions.assertArrayEquals("secret".toCharArray(), config.getKeystorePassword()); + } + + @Test + public void test20_clientSecret_isDefensivelyCopiedOnWriteAndRead() throws Exception { + char[] pwd = "app-secret".toCharArray(); + EntraIdGraphConfig config = new EntraIdGraphConfig.Builder() + .tenantId(TENANT) + .clientId(CLIENT) + .authMode(AuthMode.CLIENT_SECRET) + .clientSecret(pwd) + .build(); + // 1. Test defensive copy on WRITE (input) + Arrays.fill(pwd, '\0'); + Assertions.assertArrayEquals("app-secret".toCharArray(), config.getClientSecret()); + // 2. Test defensive copy on READ (output) + char[] first = config.getClientSecret(); + char[] second = config.getClientSecret(); + Assertions.assertNotSame(first, second); + Arrays.fill(first, '\0'); + Assertions.assertArrayEquals("app-secret".toCharArray(), second); + } +} diff --git a/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfigLoader.java b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfigLoader.java new file mode 100644 index 00000000000..3a9e29378a8 --- /dev/null +++ b/ugsync/src/test/java/org/apache/ranger/entraid/graph/TestEntraIdGraphConfigLoader.java @@ -0,0 +1,231 @@ +/* + * 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.ranger.entraid.graph; + +import org.apache.ranger.credentialapi.CredentialReader; +import org.apache.ranger.entraid.graph.EntraIdGraphConfig.AuthMode; +import org.apache.ranger.ugsyncutil.model.graph.MembershipMode; +import org.apache.ranger.unixusersync.config.UserGroupSyncConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@TestMethodOrder(MethodOrderer.MethodName.class) +public class TestEntraIdGraphConfigLoader { + private static final String TENANT = "11111111-1111-1111-1111"; + private static final String CLIENT = "22222222-2222-2222-2222"; + + private UserGroupSyncConfig config; + + @BeforeEach + public void setUp() { + config = Mockito.mock(UserGroupSyncConfig.class, Mockito.withSettings().lenient()); + } + + private void stub(String key, String value) { + Mockito.when(config.getProperty(key)).thenReturn(value); + } + + /** Stub the minimum set for a valid CERTIFICATE configuration. */ + private void stubValidCertificate() { + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "CERTIFICATE"); + stub(EntraIdGraphConfigLoader.ENTRAID_KEYSTORE_FILE, "/etc/ranger/usersync/entraid.p12"); + stub(EntraIdGraphConfigLoader.ENTRAID_CERT_ALIAS, "entraid"); + stub(EntraIdGraphConfigLoader.ENTRAID_KEYSTORE_PASSWORD, "secret"); + } + + @Test + public void test01_load_mapsCoreIdentity() throws Exception { + stubValidCertificate(); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(TENANT, result.getTenantId()); + Assertions.assertEquals(CLIENT, result.getClientId()); + } + + @Test + public void test02_load_certificateAuthType_isParsed() throws Exception { + stubValidCertificate(); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(AuthMode.CERTIFICATE, result.getAuthMode()); + } + + @Test + public void test03_load_clientSecretAuthType_isParsed() throws Exception { + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "CLIENT_SECRET"); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_SECRET, "app-secret"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(AuthMode.CLIENT_SECRET, result.getAuthMode()); + } + + @Test + public void test04_load_absentAuthType_defaultsToCertificate() throws Exception { + // No auth.type property set; loader defaults to CERTIFICATE. + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_KEYSTORE_FILE, "/etc/ranger/usersync/entraid.p12"); + stub(EntraIdGraphConfigLoader.ENTRAID_CERT_ALIAS, "entraid"); + stub(EntraIdGraphConfigLoader.ENTRAID_KEYSTORE_PASSWORD, "secret"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(AuthMode.CERTIFICATE, result.getAuthMode()); + } + + @Test + public void test05_load_invalidAuthType_throws() { + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "PASSWORD"); + EntraIdGraphConfigLoader loader = new EntraIdGraphConfigLoader(config); + Assertions.assertThrows(GraphClientException.class, loader::load); + } + + @Test + public void test06_load_membershipMode_isParsed() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_MEMBERSHIP_MODE, "TRANSITIVE"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(MembershipMode.TRANSITIVE, result.getMembershipMode()); + } + + @Test + public void test07_load_absentMembershipMode_defaultsToDirect() throws Exception { + stubValidCertificate(); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(MembershipMode.DIRECT, result.getMembershipMode()); + } + + @Test + public void test08_load_invalidMembershipMode_throws() { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_MEMBERSHIP_MODE, "SIDEWAYS"); + EntraIdGraphConfigLoader loader = new EntraIdGraphConfigLoader(config); + Assertions.assertThrows(GraphClientException.class, loader::load); + } + + @Test + public void test09_load_userSelectAttrs_parsedAsCsv() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_USER_SELECT_ATTRS, "userPrincipalName, displayName ,mail"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertTrue(result.getUserSelectAttrs().contains("userPrincipalName")); + Assertions.assertTrue(result.getUserSelectAttrs().contains("displayName")); + Assertions.assertTrue(result.getUserSelectAttrs().contains("mail")); + } + + @Test + public void test10_load_csvParsing_trimsAndSkipsBlanks() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_USER_SELECT_ATTRS, " mail , , displayName ,"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + // Empty tokens between commas must not become set entries. + Assertions.assertEquals(2, result.getUserSelectAttrs().size()); + Assertions.assertFalse(result.getUserSelectAttrs().contains("")); + } + + @Test + public void test11_load_groupFilter_isMapped() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_GROUP_FILTER, "startswith(displayName,'ENG-')"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals("startswith(displayName,'ENG-')", result.getGroupFilter()); + } + + @Test + public void test12_load_numericPageSize_isMapped() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_PAGE_SIZE, "250"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals(250, result.getPageSize()); + } + + @Test + public void test13_load_nonNumericPageSize_throws() { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_PAGE_SIZE, "lots"); + EntraIdGraphConfigLoader loader = new EntraIdGraphConfigLoader(config); + Assertions.assertThrows(GraphClientException.class, loader::load); + } + + @Test + public void test14_load_overridableEndpoints_areMapped() throws Exception { + stubValidCertificate(); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTHORITY_HOST, "https://login.partner.microsoftonline.in"); + stub(EntraIdGraphConfigLoader.ENTRAID_GRAPH_BASE_URL, "https://microsoftgraph.indiacloudapi.in"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertEquals("https://login.partner.microsoftonline.in", result.getAuthorityHost()); + Assertions.assertEquals("https://microsoftgraph.indiacloudapi.in", result.getGraphBaseUrl()); + } + + @Test + public void test15_clientSecret_directProperty_isUsed() throws Exception { + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "CLIENT_SECRET"); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_SECRET, "direct-secret"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertArrayEquals("direct-secret".toCharArray(), result.getClientSecret()); + } + + @Test + public void test16_clientSecret_fallsBackToCredStoreAlias() throws Exception { + // No direct secret; loader must resolve via CredentialReader using the alias. + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "CLIENT_SECRET"); + stub(EntraIdGraphConfigLoader.ENTRAID_CREDSTORE_FILE, "/etc/ranger/usersync/.credstore.jceks"); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_SECRET_ALIAS, "entraid.client.secret"); + try (MockedStatic cr = Mockito.mockStatic(CredentialReader.class)) { + cr.when(() -> CredentialReader.getDecryptedString(Mockito.anyString(), Mockito.eq("entraid.client.secret"), Mockito.any())).thenReturn("vaulted-secret"); + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertArrayEquals("vaulted-secret".toCharArray(), result.getClientSecret()); + } + } + + @Test + public void test17_clientSecret_directValueWinsOverCredStore() throws Exception { + stub(EntraIdGraphConfigLoader.ENTRAID_TENANT_ID, TENANT); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_ID, CLIENT); + stub(EntraIdGraphConfigLoader.ENTRAID_AUTH_TYPE, "CLIENT_SECRET"); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_SECRET, "direct-secret"); + stub(EntraIdGraphConfigLoader.ENTRAID_CREDSTORE_FILE, "/etc/ranger/usersync/.credstore.jceks"); + stub(EntraIdGraphConfigLoader.ENTRAID_CLIENT_SECRET_ALIAS, "entraid.client.secret"); + try (MockedStatic cr = Mockito.mockStatic(CredentialReader.class)) { + EntraIdGraphConfig result = new EntraIdGraphConfigLoader(config).load(); + Assertions.assertArrayEquals("direct-secret".toCharArray(), result.getClientSecret()); + // The credstore must not be consulted when a direct value is present. + cr.verifyNoInteractions(); + } + } + + @Test + public void test18_nullConfig_isRejected() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new EntraIdGraphConfigLoader(null)); + } +} diff --git a/ugsync/src/test/java/org/apache/ranger/unixusersync/process/TestPolicyMgrUserGroupBuilder.java b/ugsync/src/test/java/org/apache/ranger/unixusersync/process/TestPolicyMgrUserGroupBuilder.java index 1719f2031dc..982c6673ff7 100644 --- a/ugsync/src/test/java/org/apache/ranger/unixusersync/process/TestPolicyMgrUserGroupBuilder.java +++ b/ugsync/src/test/java/org/apache/ranger/unixusersync/process/TestPolicyMgrUserGroupBuilder.java @@ -1051,6 +1051,160 @@ public void testAJ_buildUserGroupInfo_kerberos_doAs_failure_throws() throws Exce } } + private static XUserInfo cachedUser(String name, String fullName, String syncSource, String ldapUrl, String isVisible) { + XUserInfo u = new XUserInfo(); + u.setName(name); + Map attrs = new HashMap<>(); + attrs.put(UgsyncCommonConstants.FULL_NAME, fullName); + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, syncSource); + if (ldapUrl != null) { + attrs.put(UgsyncCommonConstants.LDAP_URL, ldapUrl); + } + u.setOtherAttrsMap(attrs); + u.setOtherAttributes(JsonUtils.objectToJson(attrs)); + u.setIsVisible(isVisible); + return u; + } + + private static XGroupInfo cachedGroup(String name, String fullName, String syncSource, String ldapUrl, String isVisible) { + XGroupInfo g = new XGroupInfo(); + g.setName(name); + Map attrs = new HashMap<>(); + attrs.put(UgsyncCommonConstants.FULL_NAME, fullName); + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, syncSource); + if (ldapUrl != null) { + attrs.put(UgsyncCommonConstants.LDAP_URL, ldapUrl); + } + g.setOtherAttrsMap(attrs); + g.setOtherAttributes(JsonUtils.objectToJson(attrs)); + g.setIsVisible(isVisible); + return g; + } + + @Test + public void testW1_markDeletedUsersByFullName_marksMatchingGuid() throws Exception { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + // EntraID is a non-LDAP source: ldapUrl null, so the null==null gate applies. + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + + String guid = "aaaaaaaa-0000-0000-0000-000000000001"; + Map ucache = new HashMap<>(); + ucache.put("alice", cachedUser("alice", guid, "EntraID", null, "1")); + setPrivate(builder, "userCache", ucache); + + Method mark = PolicyMgrUserGroupBuilder.class.getDeclaredMethod("markDeletedUsersByFullName", Set.class); + mark.setAccessible(true); + mark.invoke(builder, new HashSet<>(Collections.singletonList(guid))); + + Map deletedUsers = getPrivate(builder, "deletedUsers", Map.class); + assertTrue(deletedUsers.containsKey("alice"), "user with matching GUID must be marked deleted"); + assertEquals("0", deletedUsers.get("alice").getIsVisible(), "deleted user must be set ISHIDDEN"); + } + + @Test + public void testW2_markDeletedUsersByFullName_ignoresUnmatchedGuid() throws Exception { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + + Map ucache = new HashMap<>(); + ucache.put("alice", cachedUser("alice", "guid-keep", "EntraID", null, "1")); + setPrivate(builder, "userCache", ucache); + + Method mark = PolicyMgrUserGroupBuilder.class.getDeclaredMethod("markDeletedUsersByFullName", Set.class); + mark.setAccessible(true); + mark.invoke(builder, new HashSet<>(Collections.singletonList("guid-other"))); + + Map deletedUsers = getPrivate(builder, "deletedUsers", Map.class); + assertTrue(deletedUsers.isEmpty(), "no cache record matches the delete GUID -> nothing marked"); + } + + @Test + public void testW3_markDeletedUsersByFullName_skipsDifferentSyncSource() throws Exception { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + + String guid = "shared-guid"; + Map ucache = new HashMap<>(); + // Cached record belongs to a different source; must not be deletable by EntraID. + ucache.put("alice", cachedUser("alice", guid, "Unix", null, "1")); + setPrivate(builder, "userCache", ucache); + + Method mark = PolicyMgrUserGroupBuilder.class.getDeclaredMethod("markDeletedUsersByFullName", Set.class); + mark.setAccessible(true); + mark.invoke(builder, new HashSet<>(Collections.singletonList(guid))); + + Map deletedUsers = getPrivate(builder, "deletedUsers", Map.class); + assertTrue(deletedUsers.isEmpty(), "a record from another sync source must not be deleted"); + } + + @Test + public void testW4_markDeletedUsersByFullName_skipsAlreadyHidden() throws Exception { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + + String guid = "guid-hidden"; + Map ucache = new HashMap<>(); + ucache.put("alice", cachedUser("alice", guid, "EntraID", null, "0")); // already ISHIDDEN + setPrivate(builder, "userCache", ucache); + + Method mark = PolicyMgrUserGroupBuilder.class.getDeclaredMethod("markDeletedUsersByFullName", Set.class); + mark.setAccessible(true); + mark.invoke(builder, new HashSet<>(Collections.singletonList(guid))); + + Map deletedUsers = getPrivate(builder, "deletedUsers", Map.class); + assertTrue(deletedUsers.isEmpty(), "already-hidden records must not be re-marked"); + } + + @Test + public void testW5_markDeletedGroupsByFullName_marksMatchingGuid() throws Exception { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + + String guid = "bbbbbbbb-0000-0000-0000-000000000002"; + Map gcache = new HashMap<>(); + gcache.put("Engineering", cachedGroup("Engineering", guid, "EntraID", null, "1")); + setPrivate(builder, "groupCache", gcache); + + Method mark = PolicyMgrUserGroupBuilder.class.getDeclaredMethod("markDeletedGroupsByFullName", Set.class); + mark.setAccessible(true); + mark.invoke(builder, new HashSet<>(Collections.singletonList(guid))); + + Map deletedGroups = getPrivate(builder, "deletedGroups", Map.class); + assertTrue(deletedGroups.containsKey("Engineering"), "group with matching GUID must be marked deleted"); + assertEquals("0", deletedGroups.get("Engineering").getIsVisible(), "deleted group must be set ISHIDDEN"); + } + + @Test + public void testW6_deleteUsersAndGroups_skipsDuringStartup() throws Throwable { + PolicyMgrUserGroupBuilder builder = new PolicyMgrUserGroupBuilder(); + setPrivate(builder, "currentSyncSource", "EntraID"); + setPrivate(builder, "ldapUrl", null); + setPrivate(builder, "isStartupFlag", true); // startup cycle: must not compute deletes + + String guid = "guid-startup"; + Map ucache = new HashMap<>(); + ucache.put("alice", cachedUser("alice", guid, "EntraID", null, "1")); + setPrivate(builder, "userCache", ucache); + setPrivate(builder, "ldapUgSyncClient", new FakeRest()); + + Map> delUsers = new HashMap<>(); + Map attrs = new HashMap<>(); + attrs.put(UgsyncCommonConstants.FULL_NAME, guid); + attrs.put(UgsyncCommonConstants.SYNC_SOURCE, "EntraID"); + delUsers.put(guid, attrs); + + builder.deleteUsersAndGroups(delUsers, new HashMap<>()); + + // Startup guard returns early; the cached user stays visible. + Map ucacheAfter = getPrivate(builder, "userCache", Map.class); + assertEquals("1", ucacheAfter.get("alice").getIsVisible(), "startup cycle must not mark deletes"); + } + // Helpers @SuppressWarnings("unchecked") private static T getPrivate(Object target, String field, Class type) throws Exception { diff --git a/ugsync/src/test/resources/entraid-test.p12 b/ugsync/src/test/resources/entraid-test.p12 new file mode 100644 index 00000000000..84464de34b6 Binary files /dev/null and b/ugsync/src/test/resources/entraid-test.p12 differ diff --git a/ugsync/src/test/resources/group-members.json b/ugsync/src/test/resources/group-members.json new file mode 100644 index 00000000000..e0ec713180a --- /dev/null +++ b/ugsync/src/test/resources/group-members.json @@ -0,0 +1,11 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users(id)", + "value": [ + { + "id": "11111111-aaaa-bbbb-cccc-000000000001" + }, + { + "id": "11111111-aaaa-bbbb-cccc-000000000002" + } + ] +} diff --git a/ugsync/src/test/resources/groups-delta-page.json b/ugsync/src/test/resources/groups-delta-page.json new file mode 100644 index 00000000000..5d383bac0c2 --- /dev/null +++ b/ugsync/src/test/resources/groups-delta-page.json @@ -0,0 +1,24 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", + "value": [ + { + "id": "22222222-aaaa-bbbb-cccc-000000000001", + "displayName": "Engineering", + "mailNickname": "engineering", + "securityEnabled": true + }, + { + "id": "22222222-aaaa-bbbb-cccc-000000000002", + "displayName": "Data Platform", + "mailNickname": "data-platform", + "securityEnabled": true + }, + { + "id": "22222222-aaaa-bbbb-cccc-000000000099", + "@removed": { + "reason": "deleted" + } + } + ], + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/groups/delta?$deltatoken=SAMPLE_GROUP_DELTA_TOKEN" +} diff --git a/ugsync/src/test/resources/groups-members-page1.json b/ugsync/src/test/resources/groups-members-page1.json new file mode 100644 index 00000000000..e64b116175d --- /dev/null +++ b/ugsync/src/test/resources/groups-members-page1.json @@ -0,0 +1,22 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", + "value": [ + { + "id": "G1", + "displayName": "Engineering", + "members@delta": [ + { "@odata.type": "#microsoft.graph.user", "id": "u1" }, + { "@odata.type": "#microsoft.graph.user", "id": "u2" }, + { "@odata.type": "#microsoft.graph.group", "id": "nestedGroup1" } + ] + }, + { + "id": "G2", + "displayName": "Data Platform", + "members@delta": [ + { "@odata.type": "#microsoft.graph.user", "id": "u3" } + ] + } + ], + "@odata.nextLink": "__BASE_URL__/v1.0/groups/delta/page2" +} diff --git a/ugsync/src/test/resources/groups-members-page2.json b/ugsync/src/test/resources/groups-members-page2.json new file mode 100644 index 00000000000..1485d8a3d32 --- /dev/null +++ b/ugsync/src/test/resources/groups-members-page2.json @@ -0,0 +1,17 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", + "value": [ + { + "id": "G1", + "members@delta": [ + { "@odata.type": "#microsoft.graph.user", "id": "u4" }, + { "@odata.type": "#microsoft.graph.user", "id": "u5" } + ] + }, + { + "id": "G3", + "displayName": "Empty Group" + } + ], + "@odata.deltaLink": "__BASE_URL__/v1.0/groups/delta?$deltatoken=MEMBERS_TOKEN" +} diff --git a/ugsync/src/test/resources/users-delta-page.json b/ugsync/src/test/resources/users-delta-page.json new file mode 100644 index 00000000000..bc59ec813fe --- /dev/null +++ b/ugsync/src/test/resources/users-delta-page.json @@ -0,0 +1,33 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "id": "11111111-aaaa-bbbb-cccc-000000000001", + "userPrincipalName": "alice@contoso.onmicrosoft.com", + "mail": "alice@contoso.com", + "displayName": "Alice Anderson", + "accountEnabled": true + }, + { + "id": "11111111-aaaa-bbbb-cccc-000000000002", + "userPrincipalName": "bob@contoso.onmicrosoft.com", + "mail": "bob@contoso.com", + "displayName": "Bob Brown", + "accountEnabled": true + }, + { + "id": "11111111-aaaa-bbbb-cccc-000000000003", + "userPrincipalName": "carol@contoso.onmicrosoft.com", + "mail": null, + "displayName": "Carol Clark", + "accountEnabled": false + }, + { + "id": "11111111-aaaa-bbbb-cccc-000000000099", + "@removed": { + "reason": "changed" + } + } + ], + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/users/delta?$deltatoken=SAMPLE_USER_DELTA_TOKEN" +} diff --git a/ugsync/src/test/resources/users-delta-page1.json b/ugsync/src/test/resources/users-delta-page1.json new file mode 100644 index 00000000000..47e640b73f6 --- /dev/null +++ b/ugsync/src/test/resources/users-delta-page1.json @@ -0,0 +1,20 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "id": "33333333-aaaa-bbbb-cccc-000000000001", + "userPrincipalName": "dave@contoso.onmicrosoft.com", + "mail": "dave@contoso.com", + "displayName": "Dave Davis", + "accountEnabled": true + }, + { + "id": "33333333-aaaa-bbbb-cccc-000000000002", + "userPrincipalName": "erin@contoso.onmicrosoft.com", + "mail": "erin@contoso.com", + "displayName": "Erin Evans", + "accountEnabled": true + } + ], + "@odata.nextLink": "__BASE_URL__/v1.0/users/delta/page2" +} diff --git a/ugsync/src/test/resources/users-delta-page2.json b/ugsync/src/test/resources/users-delta-page2.json new file mode 100644 index 00000000000..e5203a70378 --- /dev/null +++ b/ugsync/src/test/resources/users-delta-page2.json @@ -0,0 +1,13 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "id": "33333333-aaaa-bbbb-cccc-000000000003", + "userPrincipalName": "frank@contoso.onmicrosoft.com", + "mail": "frank@contoso.com", + "displayName": "Frank Foster", + "accountEnabled": true + } + ], + "@odata.deltaLink": "__BASE_URL__/v1.0/users/delta?$deltatoken=PAGED_SAMPLE_TOKEN" +}