SharePoint Online makes it extremely easy to share sites and content with external users. For this reason, SharePoint Online has seen rapid adoption for many extranet scenarios and in OneDrive for Business. SharePoint Online provides administrators the tools to manage external sharing, including enabling/disabling sharing and visibility into external users within a site collection. External sharing is simple, secure, and extremely powerful.  However, once content is shared externally, it stays shared forever…or at least until it is manually revoked by a content owner or administrator. In this post, I will outline a solution to set expiration timers on external sharing in SharePoint Online. The solution will also give content owners easy methods to extend/revoke external user access. This layer of external sharing governance is frequently requested by my customers and easily achievable with the Office 365 Developer platform. Here is comprehensive video overview of the solution if you want to see it in action:

 

NOTE: Although this solution is exclusive to SharePoint Online, external sharing can be delivered in a similar way on-premises. That said, Microsoft has pulled off some crazy technical gymnastics in SharePoint Online to make it effortless for users to share and IT to manage (read: don’t try this at home kids). If you really want to deliver this on-premises, I highly recommend investigating CloudExtra.net for on-premises “Extranet in a box” with SharePoint.

 

The Solution

The solution logic will be based expiration and warning thresholds. These thresholds could be tenant-wide, template-specific, site-specific, and almost anything in between. The expiration threshold represents the number of days external users will be permitted access before their access expires or is extended by a content owner. The warning threshold represents the number of days external users will be permitted access before the solution sends expiration warnings to the “invited by” content owner or site collection administrator. These email warnings will provide commands to extend the external share or immediately revoke access. If the warnings are ignored long enough to reach the expiration threshold, the external access will automatically be revoked by the solution. This “NAG” feature is very similar to Microsoft’s implementation of site disposition…just in this case we are talking external access disposition. Don’t follow? Here is a quick 50sec cartoon that simplifies the concept:

 

The solution is implemented with three components. First, a console application “timer job” will run daily to iterate site collections, capture all external users in a site, and process shares that exceed thresholds (either sending email warnings or revoking access). A database will keep track of all external users by site collection, including the original shared to date and the date their access was extended (if applicable). Finally, a website will provide content owners and site administrators an interface to respond to expiration warnings by extending or revoking external access for specific external users. The solution structure in Visual Studio can be seen below and illustrates the projects outlined above. The entire solution could be deployed to a single free Azure Website (with WebJob) and SQL Azure Database.

Detail of Solution in Visual Studio

 

Finding Detailed Information on External Sharing

The first challenge in building this solution was finding accurate external sharing details…at minimum the external user identity, invited by user identity, and shared date. This turned out to be surprisingly challenging. I started by looking at the “Access Requests” list that exists in site collections that have external sharing. This stores all outstanding and historical external sharing invitations…or so I thought. It turns out this will only track external users that haven’t already accepted a sharing request somewhere else in the entire tenant. For example…if I share “Site Collection A” with “Joe Vendor” and “Joe Vendor” is already an active external user somewhere else in the tenant (ex: “Site Collection B”), he will never show up in the “Access Requests” list.

The Office 365 Administration Portal offers an External Sharing menu that enables administrators to manually view/manage external users by site collection. If these details were exposed in the user interface, I held hope a matching API would exist in the SharePoint Online Administration assemblies. Turns out, I was right…the Microsoft.Online.SharePoint.TenantManagement namespace has an Office365Tenant class with GetExternalUsersForSite method (tip: be care not to confuse this Office365Tenant class with the slightly different Tenant class used for site collection provisioning…they are even in slight different namespaces of the assembly). The GetExternalUsersForSite method takes a site collection URL and is paged to return 50 external users at a time. I used the code below to convert ALL the external users into my own entities so I could quickly dispose the administration client context:

GetExternalUsersForSite

//use O365 Tenant Administration to get all the external sharing details for this site
List<ExternalShareDetails> shares = new List<ExternalShareDetails>();
string adminRealm = TokenHelper.GetRealmFromTargetUrl(tenantAdminUri);
var adminToken = TokenHelper.GetAppOnlyAccessToken(TokenHelper.SharePointPrincipal, tenantAdminUri.Authority, adminRealm).AccessToken;
using (var clientContext = TokenHelper.GetClientContextWithAccessToken(tenantAdminUri.ToString(), adminToken))
{
    //load the tenant
    var tenant = new Office365Tenant(clientContext);
    clientContext.Load(tenant);
    clientContext.ExecuteQuery();

    //initalize varables to going through the paged results
    int position = 0;
    bool hasMore = true;
    while (hasMore)
    {
        //get external users 50 at a time (this is the limit and why we are paging)
        var externalUsers = tenant.GetExternalUsersForSite(siteUrl, position, 50, String.Empty, SortOrder.Descending);
        clientContext.Load(externalUsers, i => i.TotalUserCount);
        clientContext.Load(externalUsers, i => i.ExternalUserCollection);
        clientContext.ExecuteQuery();

        //convert each external user to our own entity
        foreach (var extUser in externalUsers.ExternalUserCollection)
        {
            position++;
            shares.Add(new ExternalShareDetails()
            {
                AcceptedAs = extUser.AcceptedAs.ToLower(),
                DisplayName = extUser.DisplayName,
                InvitedAs = extUser.InvitedAs.ToLower(),
                InvitedBy = (String.IsNullOrEmpty(extUser.InvitedBy)) ? null : extUser.InvitedBy.ToLower(),
                UserId = extUser.UserId,
                WhenCreated = extUser.WhenCreated
            });
        }
                       
        //determine if we have more pages to process
        hasMore = (externalUsers.TotalUserCount > position);
    }
}

 

Here are the details of what GetExternalUsersForSite returns for each external user:

PropertyDescription
AcceptedAs The email address used to accept the external share
DisplayName The display name resolved when the user accepts the external share
InvitedAs The email address that was provided to share externally
InvitedBy The email address of the user that invited the external user**
UniqueId A 16-character hexadecimal unique id for the user (ex: 1003BFFD8883C6D1)
UserId User ID of the external user in the SiteUsers list for the site collection in question
WhenCreated The date the external user was first resolved in the tenant***

**InvitedBy will only contain a value if the share introduced the external user to the tenancy (ie - their first accepted invite to the tenant)

***WhenCreated returns the date the external user was first resolved in the tenant…NOT the shared date

The results from GetExternalUsersForSite provided a comprehensive list of external users for a site collection, but had a data quality issue for external users that had previously accepted external sharing requests somewhere else in my tenant (such as “Joe Vendor” mentioned earlier). For these users, the InvitedBy was empty and the WhenCreated date represented the date they first accepted an external share in my tenant (not when they accepted sharing for that specific site collection). InvitedBy isn’t that critical as I can warn the site administrator, but the original share date is essential for the expiration logic of the solution. I found an accurate date in an old friend…the user information list for the site collection (ex: _catalogs/users). This list is accessible via REST and very easy to query since GetExternalUsersForSite gives us the actual UserId of the user within the site collection. We can use the Created column to determine the accurate share date.

Using REST w/ User Information List for Actual Share Date
var shareRecord = entities.ExternalShares.FirstOrDefault(i => i.LoginName.Equals(externalShare.AcceptedAs));
if (shareRecord != null)
{
    //Update LastProcessedDate column of the record with the processDate
    shareRecord.LastProcessedDate = processDate;
    entities.SaveChanges();
}
else
{
    //get the original share date
    var details = getREST(accessToken, String.Format("{0}/_api/Web/SiteUserInfoList/Items({1})/FieldValuesAsText", siteUrl, externalShare.UserId));
    externalShare.WhenCreated = Convert.ToDateTime(details.Descendants(ns + "Created").FirstOrDefault().Value);
    shareRecord = new ExternalShare()
    {
        UniqueIdentifier = Guid.NewGuid(),
        SiteCollectionUrl = siteUrl.ToLower(),
        LoginName = externalShare.AcceptedAs,
        UserId = externalShare.UserId,
        InvitedBy = (String.IsNullOrEmpty(externalShare.InvitedBy)) ? siteOwner.Email : externalShare.InvitedBy,
        OriginalSharedDate = externalShare.WhenCreated,
        LastProcessedDate = processDate
    };
    entities.ExternalShares.Add(shareRecord);
    entities.SaveChanges();
}

 

The definitive source of external user information is collected from a combination of the GetExternalUsersForSite method AND the User Information List. The table below summarizes the sourcing.

 New External Users in TenantExisting External Users in Tenant
External User Identity GetExternalUsersForSite GetExternalUsersForSite
Invited By User Identity GetExternalUsersForSite /_api/site/Owner
Shared Date /_api/Web/SiteUserInfoList /_api/Web/SiteUserInfoList

 

Revoking and Extending Access

The Office365Tenant class has a RemoveExternalUser class, which takes an array of unique external user ids. However, this doesn’t allow you to specify a site collection so I suspect it removes the external user from the entire tenant (which we don’t want). Even if this method was site collection specific, I think it is good practice to minimize the use of the SharePoint Online Administration assembly whenever possible. In this case, GetExternalUsersForSite provided a site-specific UserId for external users, which can be used to remove them from the SiteUsers collection in the root web. This will cascade delete the external user everywhere in the site collection. Doing this could leave broken permission inheritance in the site. I originally had heartburn over this, but broken inheritance seems to be an accepted reality of the new open sharing user experience. Also notice that RefreshShareDate takes precedence over OriginalShareDate…this is how we take extending access into consideration (RefreshShareDate will be null for any external user that hasn’t been extended).

Revoke External User by Deleting SiteUser

//check if the record falls inside the warnings
double daysActive = processDate.Subtract(shareRecord.OriginalSharedDate).TotalDays;
if (shareRecord.RefreshSharedDate != null)
    daysActive = processDate.Subtract((DateTime)shareRecord.RefreshSharedDate).TotalDays;

//check for cutoff
if (daysActive > cutoffDuration)
{
    //remove the SPUser from the site
    clientContext.Web.SiteUsers.RemoveById(externalShare.UserId);
    clientContext.ExecuteQuery();

    //delete the record
    entities.ExternalShares.Remove(shareRecord);
    entities.SaveChanges();
}

 

To extend access, we will allow content owners and site collection administrators to reset the expiration clock through an MVC web application. Below you can see the code used to send expiration warnings (which contain direct links to extend/revoke views). Notice that the solution leverages GUIDs to provide direct links to controller actions.

Sending Expiration Warnings
else if (daysActive > warningDuration)
{
    int expiresIn = Convert.ToInt32(cutoffDuration - daysActive);
    //send email to InvitedBy (which will be site collection owner when null)
    EmailProperties email = new EmailProperties();
    email.To = new List<String>() { shareRecord.InvitedBy };
    email.Subject = String.Format("Action Required: External sharing with {0} about to expire", externalShare.AcceptedAs);
    email.Body = String.Format("<html><body><p>You are receiving this message because you are the site administrator of <a href='{0}'>{0}</a> OR you shared it with {1}. The external access for this user is set to expire in {2} days. Use the link below to view additional details and perform actions to revoke OR extend access for another {3} days. If you do not act on this notice, the external access for this user to terminate in {2} days.</p><ul><li><a href='{4}Details/{5}'>View Details</a></li><li><a href='{4}Extend/{5}'>Extend {3} Days</a></li><li><a href='{4}Revoke/{5}'>Revoke Access</a></li></ul></body></html>", siteUrl, externalShare.AcceptedAs, expiresIn.ToString(), cutoffDuration.ToString(), webUrl, shareRecord.UniqueIdentifier);
    Utility.SendEmail(clientContext, email);
    clientContext.ExecuteQuery();
}

 

Expiration Warning Email

Below is the MVC controller for both Extend and Revoke. Revoke in the controller is identical to the “timer job” console application and Extend simply sets the RefreshShareDate.

MVC Controller

// GET: Details/92128104-7BA4-4FEE-BB6C-91CCE968F4DD
public ActionResult Details(string id)
{
    if (id == null)
    {
        return View("Error");
    }
    Guid uniqueID;
    try
    {
        uniqueID = new Guid(id);
    }
    catch (Exception)
    {
        return View("Error");
    }
    ExternalShare externalShare = db.ExternalShares.FirstOrDefault(i => i.UniqueIdentifier == uniqueID);
    if (externalShare == null)
    {
        return View("Error");
    }
    return View(externalShare);
}

// GET: Extend/92128104-7BA4-4FEE-BB6C-91CCE968F4DD
public ActionResult Extend(string id)
{
    if (id == null)
    {
        return View("Error");
    }
    Guid uniqueID;
    try
    {
        uniqueID = new Guid(id);
    }
    catch (Exception)
    {
        return View("Error");
    }
    ExternalShare externalShare = db.ExternalShares.FirstOrDefault(i => i.UniqueIdentifier == uniqueID);
    if (externalShare == null)
    {
        return View("Error");
    }

    //update the share with a new RefreshSharedDate
    externalShare.RefreshSharedDate = DateTime.Now;
    db.SaveChanges();

    return View(externalShare);
}

// GET: Revoke/92128104-7BA4-4FEE-BB6C-91CCE968F4DD
public ActionResult Revoke(string id)
{
    if (id == null)
    {
        return View("Error");
    }
    Guid uniqueID;
    try
    {
        uniqueID = new Guid(id);
    }
    catch (Exception)
    {
        return View("Error");
    }
    ExternalShare externalShare = db.ExternalShares.FirstOrDefault(i => i.UniqueIdentifier == uniqueID);
    if (externalShare == null)
    {
        return View("Error");
    }

    //get an AppOnly accessToken and clientContext for the site collection
    Uri siteUri = new Uri(externalShare.SiteCollectionUrl);
    string realm = TokenHelper.GetRealmFromTargetUrl(siteUri);
    string accessToken = TokenHelper.GetAppOnlyAccessToken(TokenHelper.SharePointPrincipal, siteUri.Authority, realm).AccessToken;
    using (var clientContext = TokenHelper.GetClientContextWithAccessToken(siteUri.ToString(), accessToken))
    {
        //remove the SPUser from the site
        clientContext.Web.SiteUsers.RemoveById(externalShare.UserId);
        clientContext.ExecuteQuery();

        //delete the record
        db.ExternalShares.Remove(externalShare);
        db.SaveChanges();
    }

    //display the confirmation
    return View(externalShare);
}

 

Warning Detail in MVC App

Revoke Confirmation in MVC App

Extend Confirmation in MVC App

You might be wondering where those important warning and expiration thresholds are configured. For simplicity in this solution, I configured them in the appSettings section of the console app and MVC app configuration files. However, the solution could be configured to implement more advanced threshold logic such as template-specific thresholds.

Configuration appSettings
  <appSettings>
    <add key="ClientID" value="YOUR_APP_ID" />
    <add key="ClientSecret" value="YOUR_APP_SECRET" />
    <add key="WarningDuration" value="50" />
    <add key="CutoffDuration" value="60" />
    <add key="TenantName" value="rzna" />
    <add key="TenantUpnDomain" value="rzna.onmicrosoft.com" />
  </appSettings>

 

Final Thoughts

I hope this solution helped illustrate how Office 365 can deliver great sharing user experience WITH the sharing compliance/governance that legal teams and information security are demanding. You can download the entire solution on the Office 365 Developer Patterns and Practices (PnP) site of GitHub and view deployment details HERE