Last active
September 30, 2020 22:57
-
-
Save merill/97d274d074368ad4dcff69d591d15963 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.Graph; | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using App.Azure.GuestInvite.Infrastructure.Authentication; | |
using App.Azure.GuestInvite.Model; | |
namespace App.Azure.GuestInvite.Infrastructure | |
{ | |
public class GraphClient : IGraphClient | |
{ | |
private readonly GraphServiceClient _graphClient; | |
private IGraphServiceUsersCollectionPage _users; | |
public GraphClient(string tenant, string clientId, string[] scopes) | |
{ | |
_graphClient = new MsalAuthenticationHelper().GetAuthenticatedClient(tenant, clientId, scopes); | |
} | |
public bool HasMoreUsers => _users.NextPageRequest != null; | |
public IGraphServiceUsersCollectionPage GetNextPageUsers() | |
{ | |
return _users.NextPageRequest.GetAsync().Result; | |
} | |
private IGraphServiceUsersCollectionPage GetUsers(QueryOption option) | |
{ | |
var options = new List<Option> { option }; | |
_users = _graphClient.Users.Request(options).GetAsync().Result; | |
return _users; | |
} | |
public IGraphServiceUsersCollectionPage GetUsers(string fieldName, string value) | |
{ | |
return GetUsers(GetQueryOption(GetODataFilter(fieldName, value))); | |
} | |
public Invitation InviteGuest(Invitation invitation) | |
{ | |
return _graphClient.Invitations.Request().AddAsync(invitation).Result; | |
} | |
public void RemoveGuest(string id) | |
{ | |
_graphClient.Users[id].Request().DeleteAsync().GetAwaiter().GetResult(); | |
} | |
public IList<Store> GetStores(string tenantName, string siteCollectionId, string webId, string listId) | |
{ | |
var result = _graphClient.Sites[$"{tenantName},{siteCollectionId},{webId}"].Lists[listId].Items.Request() | |
.Expand("fields($select%3DTitle,Code,Region,OrgUnitCode)").GetAsync().Result; | |
var stores = new List<Store>(result.Count); | |
do | |
{ | |
stores.AddRange(result.CurrentPage.Select(item => new Store | |
{ | |
Title = item.Fields.AdditionalData.ContainsKey("Title") ? item.Fields.AdditionalData["Title"] as string : string.Empty, | |
Code = item.Fields.AdditionalData.ContainsKey("Code") ? item.Fields.AdditionalData["Code"] as string : string.Empty, | |
Region = item.Fields.AdditionalData.ContainsKey("Region") ? item.Fields.AdditionalData["Region"] as string : string.Empty, | |
OrgUnitCode = item.Fields.AdditionalData.ContainsKey("OrgUnitCode") ? item.Fields.AdditionalData["OrgUnitCode"] as string : string.Empty, | |
})); | |
} while (result.NextPageRequest != null && (result = result.NextPageRequest.GetAsync().Result).Count > 0); | |
return stores; | |
} | |
public IList<User> GetExternalUsers(string externalEmailDomain, string tenantName) | |
{ | |
var list = new List<User>(); | |
// Remove the @ symbol | |
var domain = externalEmailDomain.Substring(1); | |
// External Users UserPrincipalName is of the form FirstName.LastName_externaldomain.com#EXT#@contoso.onmicrosoft.com | |
var filter = $"_{domain}#EXT#@{tenantName}"; | |
var result = _graphClient.Users.Request().Select("id,displayName,userPrincipalName,userType").GetAsync().Result; | |
do | |
{ | |
var users = result.CurrentPage; | |
foreach (var user in users) | |
{ | |
if (user.UserPrincipalName.EndsWith(filter, StringComparison.OrdinalIgnoreCase)) | |
{ | |
// Need to convert the UPN to the normal email format without the #EXT# | |
user.UserPrincipalName = user.UserPrincipalName.Replace(filter, $"@{domain}"); | |
list.Add(user); | |
} | |
} | |
} | |
while (result.NextPageRequest != null && (result = result.NextPageRequest.GetAsync().Result).Count > 0); | |
return list; | |
} | |
public IList<User> GetGroupMembers(string groupId) | |
{ | |
var result = _graphClient.Groups[groupId].Members.Request().GetAsync().Result; | |
var members = new List<User>(result.Count); | |
do | |
{ | |
members.AddRange(result.CurrentPage.OfType<User>()); | |
} | |
while (result.NextPageRequest != null && (result = result.NextPageRequest.GetAsync().Result).Count > 0); | |
return members; | |
} | |
private string GetODataFilter(string fieldName, string value) | |
{ | |
return $"{fieldName} eq '{value}'"; | |
} | |
private QueryOption GetQueryOption(string filter) | |
{ | |
return new QueryOption("$filter", filter); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using CsvHelper; | |
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics.CodeAnalysis; | |
using System.IO; | |
using System.Linq; | |
using App.Azure.GuestInvite.Infrastructure; | |
using App.Azure.GuestInvite.Model; | |
using File = System.IO.File; | |
namespace App.Azure.GuestInvite.Business | |
{ | |
public class GraphQuery | |
{ | |
private readonly IGraphClient _gc; | |
private readonly string[] _scopes = { "User.Read.All", "Sites.Read.All", "Directory.AccessAsUser.All" }; | |
public GraphQuery() | |
{ | |
_gc = new GraphClient(AppConfiguration.TeamApp.Tenant, AppConfiguration.TeamApp.ClientId, _scopes); | |
} | |
public GraphQuery(IGraphClient gc) | |
{ | |
_gc = gc; | |
} | |
public void Connect() | |
{ | |
//Run a quick test to confirm that we can connect to the tenant and user has required permissions. | |
//Use a random orgunit. This is just to check if user has access. | |
_gc.GetUsers(AppConfiguration.OrgUnitExtensionName, "000"); | |
} | |
public void CreateGuestUsersFile() | |
{ | |
var uniqueUsers = new Dictionary<string, UserInfo>(); | |
PrepGuestUsersFile(AppConfiguration.GuestUsersFile); | |
var stores = GetStores(); | |
foreach (var store in stores) | |
{ | |
var orgUnit = store.OrgUnitCode; | |
Console.WriteLine("Processing " + orgUnit); | |
if (orgUnit is null) | |
{ | |
throw new NullReferenceException($"Store OrgUnitCode is null. Store: {store.Title}, Code: {store.Code}, Region: {store.Region}"); | |
} | |
AddUniqueUsers(GetUsersInOrgUnit(orgUnit), ref uniqueUsers); | |
// AppendUsersToCsv(users); | |
} | |
// Ad Hoc Users | |
AddUniqueUsers(GetAdHocUsers(), ref uniqueUsers); | |
AppendUsersToCsv(uniqueUsers.Values.ToList()); | |
var msg = "Successfully finished creating GuestUsers.txt file."; | |
App.Log.Info(msg); | |
Console.WriteLine(msg); | |
} | |
private static void AddUniqueUsers(IList<UserInfo> users, ref Dictionary<string, UserInfo> uniqueUsers) | |
{ | |
foreach (var user in users) | |
{ | |
if (!uniqueUsers.ContainsKey(user.UserPrincipalName)) | |
{ | |
uniqueUsers[user.UserPrincipalName] = user; | |
} | |
else | |
{ | |
App.Log.Info($"Duplicate OrgUnit user found: {user.UserPrincipalName}"); | |
} | |
} | |
} | |
public IEnumerable<Store> GetStores() | |
{ | |
App.Log.Debug("GetStores"); | |
//return File.ReadAllLines(AppConfiguration.TeamApp.OrgUnitFilterFile); | |
try | |
{ | |
return _gc.GetStores( | |
AppConfiguration.SharePointTenant, | |
AppConfiguration.SharePointSiteCollectionId, | |
AppConfiguration.SharePointWebId, | |
AppConfiguration.SharePointStoresListId); | |
} | |
catch (AggregateException ae) | |
when ((ae?.InnerException?.Message ?? string.Empty).Contains("Code: accessDenied")) | |
{ | |
Console.WriteLine($"User does not have access to SharePoint List"); | |
App.Log.Error($"User does not have access to SharePoint List"); | |
throw ae.InnerException; | |
} | |
} | |
private void AppendUsersToCsv(IList<UserInfo> users) | |
{ | |
using (var csv = new CsvWriter(new StreamWriter(AppConfiguration.GuestUsersFile, true))) | |
{ | |
csv.Configuration.HasHeaderRecord = false; | |
csv.WriteRecords(users); | |
} | |
} | |
private List<UserInfo> GetUsersInOrgUnit(string orgUnit) | |
{ | |
var filteredUsers = new List<UserInfo>(); | |
var users = _gc.GetUsers(AppConfiguration.OrgUnitExtensionName, orgUnit); | |
var domainChecker = new DomainChecker(AppConfiguration.TeamApp.DomainFilter); | |
do | |
{ | |
foreach (var user in users) | |
{ | |
if (domainChecker.IsValidDomain(user.UserPrincipalName)) | |
{ | |
filteredUsers.Add(new UserInfo() { UserPrincipalName = user.UserPrincipalName, DisplayName = user.DisplayName, OrgUnit = orgUnit }); | |
} | |
else | |
{ | |
App.Log.Info($"Skipping. User does not match domain filter: {user.UserPrincipalName}"); | |
} | |
} | |
} | |
while (_gc.HasMoreUsers && (users = _gc.GetNextPageUsers()).Count > 0); | |
return filteredUsers; | |
} | |
public IList<UserInfo> GetAdHocUsers() | |
{ | |
var domainChecker = new DomainChecker(AppConfiguration.TeamApp.DomainFilter); | |
var filteredUsers = new List<UserInfo>(); | |
var users = _gc.GetGroupMembers(AppConfiguration.UPOSApplicationSupportGroupId); | |
foreach (var user in users) | |
{ | |
if (domainChecker.IsValidDomain(user.UserPrincipalName)) | |
{ | |
filteredUsers.Add(new UserInfo | |
{ | |
UserPrincipalName = user.UserPrincipalName, | |
DisplayName = user.DisplayName, | |
UserType = "Guest", | |
}); | |
} | |
else | |
{ | |
App.Log.Info($"Skipping. Ad-Hoc user does not match domain filter: {user.UserPrincipalName}"); | |
} | |
} | |
return filteredUsers; | |
} | |
private void PrepGuestUsersFile(string guestUsersFile) | |
{ | |
App.Log.Debug("PrepGuestUsersFile - " + guestUsersFile); | |
if (File.Exists(guestUsersFile)) | |
{ | |
Console.WriteLine("WARNING: A guest users file already exists at the following location and will be OVERWRITTEN."); | |
Console.WriteLine(guestUsersFile); | |
File.Delete(guestUsersFile); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* DomainChecker.cs */ | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
namespace App.Azure.GuestInvite.Business | |
{ | |
public class DomainChecker | |
{ | |
private readonly IEnumerable<string> _domains; | |
public DomainChecker(string domainList) | |
{ | |
_domains = domainList.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()); | |
} | |
public bool IsValidDomain(string email) | |
{ | |
var userInDomain = false; | |
foreach (var domain in _domains) | |
{ | |
if (email.EndsWith(domain, StringComparison.InvariantCultureIgnoreCase)) | |
{ | |
userInDomain = true; | |
break; | |
} | |
} | |
return userInDomain; | |
} | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using CsvHelper; | |
using Microsoft.Graph; | |
using System.Collections.Generic; | |
using System.Diagnostics.CodeAnalysis; | |
using System.IO; | |
using System.Linq; | |
using App.Azure.GuestInvite.Infrastructure; | |
using App.Azure.GuestInvite.Model; | |
using Microsoft.ApplicationInsights.DataContracts; | |
namespace App.Azure.GuestInvite.Business | |
{ | |
public class InviteManager | |
{ | |
private readonly IGraphClient _gc; | |
private readonly string _tenant; | |
// These three scopes are required to be able to invite guest users to the tenant | |
private readonly string[] _scopes = { "User.Invite.All", "User.ReadWrite.All", "Directory.ReadWrite.All" }; | |
public InviteManager() | |
{ | |
_tenant = AppConfiguration.RetailApp.Tenant; | |
_gc = new GraphClient(_tenant, AppConfiguration.RetailApp.ClientId, _scopes); | |
} | |
public InviteManager(IGraphClient gc) | |
{ | |
_gc = gc; | |
} | |
public void Connect() | |
{ | |
//Connectivity test. Add this fixed admin account which will always exist. | |
var user = new UserInfo {UserPrincipalName = "admin@App.com", DisplayName = "FirstName LastName", UserType = "Guest"}; | |
InviteGuest(user); | |
} | |
public void DoInvites() | |
{ | |
// Retrieve all users that should be invited from the CSV file | |
var users = GetUsersFromCsv(AppConfiguration.GuestUsersFile); | |
var domainChecker = new DomainChecker(AppConfiguration.TeamApp.DomainFilter); | |
foreach (var user in users) | |
{ | |
if (domainChecker.IsValidDomain(user.UserPrincipalName)) | |
{ | |
if (IsUserInTenant(user)) | |
{ | |
var msg = "Skiping. User already in tenant: " + user.UserPrincipalName; | |
App.Log.Info(msg); | |
Console.WriteLine(msg); | |
} | |
else | |
{ | |
InviteGuest(user); | |
} | |
} | |
else | |
{ | |
App.Log.Info("Skipping. User does not match domain filter: " + user.UserPrincipalName); | |
} | |
} | |
var message = "Completed inviting users"; | |
App.Log.Info(message); | |
Console.WriteLine(message); | |
} | |
/// <summary>Synchronizes the users.</summary> | |
public void SynchronizeUsers() | |
{ | |
// Retrieve all users that should be invited from the CSV file | |
var usersThatShouldBeInvited = GetUsersFromCsv(AppConfiguration.GuestUsersFile); | |
var domainChecker = new DomainChecker(AppConfiguration.TeamApp.DomainFilter); | |
// Retrieve all external users already in tenant | |
var azureAdExternalUsers = _gc.GetExternalUsers(AppConfiguration.TeamApp.DomainFilter, AppConfiguration.RetailApp.Tenant) | |
.Select(u => new UserInfo() | |
{ | |
UserPrincipalName = u.UserPrincipalName, | |
DisplayName = u.DisplayName, | |
UserType = u.UserType, | |
Id = u.Id | |
}); | |
// Compare the two lists. Users in invitee list that are not in the AD list should be added | |
var usersToAdd = usersThatShouldBeInvited.Except(azureAdExternalUsers).ToList(); | |
// Compare the two lists. Users in AD that are not in the invitee list should be removed | |
var usersToRemove = azureAdExternalUsers.Except(usersThatShouldBeInvited).ToList(); | |
foreach (var user in usersToAdd) | |
{ | |
if (domainChecker.IsValidDomain(user.UserPrincipalName)) | |
{ | |
if (!AppConfiguration.WhatIf) | |
{ | |
InviteGuest(user); | |
} | |
else | |
{ | |
Console.WriteLine($"Would have added user: {user.UserPrincipalName} ({(user.UserType == "Guest" ? "Ad-Hoc" : user.OrgUnit)})"); | |
} | |
} | |
else | |
{ | |
var message = $"Skipping invite. User does not match domain filter: { user.UserPrincipalName}"; | |
App.Log.Warn(message); | |
Console.WriteLine(message); | |
} | |
} | |
foreach (var user in usersToRemove) | |
{ | |
if (domainChecker.IsValidDomain(user.UserPrincipalName)) | |
{ | |
if (!AppConfiguration.WhatIf && AppConfiguration.EnableRemovals) | |
{ | |
RemoveGuest(user); | |
} | |
else if (AppConfiguration.EnableRemovals) | |
{ | |
Console.WriteLine($"Would have removed user: {user.UserPrincipalName}"); | |
} | |
} | |
else | |
{ | |
var message = $"Skipping remove. User does not match domain filtert: {user.UserPrincipalName}"; | |
App.Log.Warn(message); | |
Console.WriteLine(message); | |
} | |
} | |
} | |
private static IEnumerable<UserInfo> GetUsersFromCsv(string guestUsersFile) | |
{ | |
using (var csv = new CsvReader(new StreamReader(guestUsersFile), new CsvHelper.Configuration.Configuration | |
{ | |
HasHeaderRecord = false, | |
HeaderValidated = null, | |
MissingFieldFound = null | |
})) | |
{ | |
var users = csv.GetRecords<UserInfo>().ToList(); | |
return users; | |
} | |
} | |
private bool IsUserInTenant(UserInfo user) | |
{ | |
var userInTenant = false; | |
try | |
{ | |
var users = _gc.GetUsers("mail", user.UserPrincipalName); | |
userInTenant = users.Count > 0; | |
} | |
catch (Exception ex) | |
{ | |
App.Log.Error("Error searching for user " + user.UserPrincipalName, ex); | |
} | |
return userInTenant; | |
} | |
private void InviteGuest(UserInfo user) | |
{ | |
var invite = new Invitation | |
{ | |
InvitedUserEmailAddress = user.UserPrincipalName, | |
InvitedUserDisplayName = user.DisplayName, | |
InviteRedirectUrl = "https://myapps.microsoft.com", | |
InvitedUserType = user.UserType, | |
SendInvitationMessage = false | |
}; | |
var isInvited = false; | |
var status = string.Empty; | |
try | |
{ | |
var inviteResult = _gc.InviteGuest(invite); | |
isInvited = (inviteResult.Status == "Accepted" || inviteResult.Status == "Completed"); | |
status = inviteResult.Status; | |
AppConfiguration.TelementryClient.TrackEvent( | |
"User Invited", | |
new Dictionary<string, string> { | |
{ "UserPrincipalName", user.UserPrincipalName }}); | |
} | |
catch (Exception ex) | |
{ | |
App.Log.Error("Error inviting guest", ex); | |
} | |
var message = isInvited | |
? user.UserPrincipalName + " - added successfully as guest" | |
: user.UserPrincipalName + " - failed adding as guest. Status:" + status; | |
App.Log.Info(message); | |
Console.WriteLine(message); | |
} | |
private void RemoveGuest(UserInfo user) | |
{ | |
try | |
{ | |
_gc.RemoveGuest(user.Id); | |
AppConfiguration.TelementryClient.TrackEvent( | |
"User Removed", | |
new Dictionary<string, string> { | |
{ "UserPrincipalName", user.UserPrincipalName }}); | |
var message = $"{user.UserPrincipalName} removed from {_tenant}"; | |
App.Log.Info(message); | |
Console.WriteLine(message); | |
} | |
catch (Exception e) | |
{ | |
App.Log.Error($"Error removing guest {user.UserPrincipalName}", e); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment