Leveraging Office PnP Core to run multi tenant specific operations

Series:
1. Securing React App with Azure AD *this post
2. Setting Up Azure Key Vault with an Azure Website (Web API) 
3. Leveraging Office Pnp Core to run multi tenant specific operations (Create modern site, etc) *this post
4. Creating communication sites templates and applying them to new sites in different tenants.

In this post its where we actually start to see really nice stuff, specially for those involved more into Sharepoint Development, as a Solutions Architect I need to think outside the box, and get out of my comfort zone, which for year has been developing Sharepoint Solutions in the last 12 years, and before that .Net for 7 years. This time I had to learn about Azure Key Vault, I had to learn about identity management with Azure Active Directory, I had to learn a little bit about CosmosDB and other great Azure Services, and I have finally arrived to a working solution that its worth showing.

This blog post is only a sneak peek of what the solution can do, its not the entire code of all the functionality, I hope that very soon I can show this in action in any conference or my own recording showing the entire product that is under development.

Enough said, Show me the code.!

As mentioned in the first post of the series, we have a front end application in React which is secured in AD, and this front end consumes a web api, which is also secured with AD, the web api is actually configured to allow only calls from one specific front end, so that adds a layer of security in this context.

In the second post I showed how to setup Azure key Vault and the WebApp, so that the last one can consume resources from the key vault via a trusted “connection”.

And finally in this post I will show how I created some method that will allow to create multi tenant specific operations from the same web application, things like creating a modern site, a communication site, etc.

The beauty of this entire concept, its that once I release the code to the community later this year, then the community can actually start adding operations that are not only Sharepoint Related, but Microsoft Graph related, or Teams, etc, making this a huge platform.

When the code is released it will be released as Lite Version, Free to download and use in your projects.

CosmosDB Setup

What do we store in CosmosDB, well we store all information about the application, we store the tenants, we store the page templates, the site collections, etc. For this we need a way to connect to our datase

Create the web.config settings for cosmos db
  <add key="endpoint" value="https://xxxyyy.documents.azure.com:443/" />
    <add key="authKey" value="abc" />
    <add key="database" value="YourDatabaseName" />
    <add key="collection" value="shared" />

Endpoint: CosmosDB endpoint, you will get it from the CosmosDB Blade.

AuthKey: You get this from the CosmosDB blade in Azure to get access from external applications.

Database: The name of the databse, you are free to create it first, if not, then our Cosmonaut library will create it for you.

Collection: In order to reduce costs, I recommend to use shared collections, basically on the same collection and with a partition key we can store many entities, you will see this later.

CosmosDB Library

Instead of relying on CosmosDB SDK, I decided to go with Cosmonaut, Cosmonaut its an open source library created by my friend and also MVP Nick Chapsas. This library which is also published as a nuget package hides all the database connection logic under the hood and allows you to focus on what delivers value to your users: your business requirements. I cant stress how much time Cosmonaut has saved me when creating this application, huge time savings

Other benefits listed here:
1. Cost optimization
2. Paging out of the box in one line!
3. Fluent Async operations for querying out of the box
4. Automatic database and collection provisioning

Information about the library can be found here:
https://github.com/Elfocrash/Cosmonaut

As my WebAPI is not .net Core, but only web api 2, I initialize the Cosmos Store Holders for each entity in Global.Asax, once this is done, then we can reference them from any api controllers

Entities

Each entity we want to save in the database, we need to create it with some attributes, specially if its a shared collection.

 [SharedCosmosCollection("shared")]
    public class SharepointTenant : ISharedCosmosEntity
    {
        [JsonProperty("Id")]
        public string Id { get; set; }
        [CosmosPartitionKey]
        public string CosmosEntityName { get; set; }
        public string TestSiteCollectionUrl { get; set; }
        public string TenantName { get; set; }
        public string Email { get; set; }
        public string Password { get; set; }
        public string SecretIdentifier { get; set; }
        public bool Active { get; set; }
        public override string ToString()
        {
            return JsonConvert.SerializeObject(this);
        }
    }

Steps to done this correctly:
1. Implement the interface ISharedCosmosEntity by creating CosmosEntityName property
2. Add the SharedCosmosCollection attribute to the class
3. Add a JsonProperty for the id property.

CosmosStoreHolder

This is the class mentioned before that will simplify our lives

using Cosmonaut;  
using Microsoft.Azure.Documents.Client;  
using System;  
using System.Collections.Generic;  
using System.Configuration;  
using System.Linq;  
using System.Web;  
using TenantManagementWebApi.Entities;


namespace TenantManagementWebApi.Components  
{
    public sealed class CosmosStoreHolder
    {
        private static CosmosStoreHolder instance = null;
        private static readonly object padlock = new object();
        public Cosmonaut.ICosmosStore<SharepointTenant> CosmosStoreTenant { get; }
        public Cosmonaut.ICosmosStore<SiteCollection> CosmosStoreSiteCollection { get; }
        public Cosmonaut.ICosmosStore<PageTemplate> CosmosStorePageTemplate { get; }
        public Cosmonaut.ICosmosStore<Page> CosmosStorePage { get; }


        CosmosStoreHolder()
        {

            CosmosStoreSettings settings = new Cosmonaut.CosmosStoreSettings(ConfigurationManager.AppSettings["database"].ToString(),
                 ConfigurationManager.AppSettings["endpoint"].ToString(),
                 ConfigurationManager.AppSettings["authKey"].ToString());

            settings.ConnectionPolicy = new ConnectionPolicy
            {
                ConnectionMode = ConnectionMode.Direct,
                ConnectionProtocol = Microsoft.Azure.Documents.Client.Protocol.Tcp
            };


            CosmosStoreTenant = new Cosmonaut.CosmosStore<SharepointTenant>(settings);
            CosmosStoreSiteCollection = new Cosmonaut.CosmosStore<SiteCollection>(settings);
            CosmosStorePageTemplate = new Cosmonaut.CosmosStore<PageTemplate>(settings);
            CosmosStorePage = new Cosmonaut.CosmosStore<Page>(settings);
        }

        public static CosmosStoreHolder Instance
        {
            get
            {
                lock (padlock)
                {
                    if (instance == null)
                    {
                        instance = new CosmosStoreHolder();
                    }
                    return instance;
                }
            }
        }
    }
}

So this is basically a singleton where we initialize our CosmosStores object that then we will use across the application, be sure to initialize it in global.asax

 protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
            var instancePage = CosmosStoreHolder.Instance.CosmosStorePage;
            var instancePageTemplate = CosmosStoreHolder.Instance.CosmosStorePageTemplate;
            var instanceSiteCollection = CosmosStoreHolder.Instance.CosmosStoreSiteCollection;
            var instanceTenant = CosmosStoreHolder.Instance.CosmosStoreTenant;

        }

Until here, no business logic, only database related setup code.

TenantController

This is the entry point for new tenants, here we will register the tenants that we will manage from our web application, as mentioned in the previous post, due to lack of support for App-Only calls to create modern sites, yeah Vesa I am looking at you :), please consider it.

Anyway, in order to authenticate with multiple tenants, I need to have a tenant admin account and password, so I will store the tenant information in the ComosDB, but the password will be stored in Azure KeyVault, for an extra layer of security.

using System.Collections.Generic;  
using System.Linq;  
using System.Net;  
using System.Threading.Tasks;  
using System.Web.Http;  
using TenantManagementWebApi.Entities;  
using Cosmonaut.Extensions;  
using TenantManagementWebApi.Components;

namespace TenantManagementWebApi.Controllers  
{
    [Authorize]
    public class TenantController : ApiController
    {

        [HttpGet]
        [Route("api/Tenant/GetActiveTenant")]
        public async Task<SharepointTenant> GetActiveTenant()
        {
            var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;
            return await tenantStore.Query().Where(x => x.Active == true).FirstOrDefaultAsync();

        }

        [HttpGet]
        public async Task<List<SharepointTenant>> GetTenants()
        {
            var tenantStore =CosmosStoreHolder.Instance.CosmosStoreTenant;
            return await tenantStore.Query().ToListAsync();
        }


        [HttpGet]
        public async Task<IHttpActionResult> GetTenant(string id)
        {
            var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;
            var tenant = await tenantStore.Query().FirstOrDefaultAsync(x => x.Id == id);
            if (tenant == null)
            {
                return NotFound();
            }
            return Ok(tenant);
        }


        [HttpPut]
        public async Task<IHttpActionResult> PutTenant([FromBody]SharepointTenant tenant)
        {
            try
            {
                using (var context = new OfficeDevPnP.Core.AuthenticationManager().GetSharePointOnlineAuthenticatedContextTenant(tenant.TestSiteCollectionUrl, tenant.Email, tenant.Password))
                {
                    context.Load(context.Web, p => p.Title);
                    context.ExecuteQuery();
                };

                string domainUrl = tenant.TestSiteCollectionUrl;
                string tenantName = domainUrl.Split('.')[0].Remove(0,8);

                tenant.Active = false;
                tenant.TenantName = tenantName;

                KeyVaultHelper keyVaultHelper = new KeyVaultHelper();
                await keyVaultHelper.OnCreateAsync(tenant.TenantName, tenant.Password);
                tenant.Password = "Hidden";
                tenant.SecretIdentifier = keyVaultHelper.SecretIdentifier;

                var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }

                var added = await tenantStore.AddAsync(tenant);
                return StatusCode(HttpStatusCode.NoContent);
            }
            catch (System.Exception ex)
            {
                return BadRequest("Invalid information entered, cant authenticate.");
            }
        }


        [HttpPost]
        public async Task<IHttpActionResult> PostTenant(string id, SharepointTenant tenant)
        {
            try
            {
                var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;
                tenant.Active = false;
                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }

                using (var context = new OfficeDevPnP.Core.AuthenticationManager().GetSharePointOnlineAuthenticatedContextTenant(tenant.TestSiteCollectionUrl, tenant.Email, tenant.Password))
                {
                    context.Load(context.Web, p => p.Title);
                    context.ExecuteQuery();
                };

                var result = await tenantStore.UpdateAsync(tenant);
                return Ok(result);
            }
            catch (System.Exception)
            {
                return BadRequest("Invalid information entered, cant authenticate.");
            }

        }

        [HttpPost]
        [Route("api/Tenant/SetTenantActive")]
        public async Task<IHttpActionResult> SetTenantActive(string TenantName)
        {
            var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;
            var allTenants = await tenantStore.Query().Where(x => x.TenantName != null).ToListAsync();
            foreach(SharepointTenant ten  in allTenants)
            {
                ten.Active = false;
                await tenantStore.UpdateAsync(ten);
            }

            var tenant = await tenantStore.Query().FirstOrDefaultAsync(x => x.TenantName == TenantName);
            if (tenant == null)
            {
                return NotFound();
            }

            tenant.Active = true;
            var result = await tenantStore.UpdateAsync(tenant);

            return Ok(result);
        }
    }
}

In the code above, I have methods to create tenants, get tenants, delete, etc. One of the important things here is the SetTenantActive, why do I have this method? Basically because from the user interface, if you want to create page templates, deploy webparts, create site collections based on templates, etc, they all need to happen in the context of a tenant, so in the tenant UI, which you will see later in this post, there’s a checkbox on each row, in order to set that tenant as active, any option the user does afterwards will be executed on the context of that tenant.

The Post Tenant method shows how to add a tenant to the database, but the important thing here is that before adding the information, we need to validate the user entered information, to see if the password is correct or not, we just execute a basic operation like getting the Web.Title, if that works, then the tenant is registered.

Tenant User Interface.

Here, the react component where I show to get the tenant list, and how to trigger the SetTenantActive endpoint.

Create a communication site.

In the Site collection controller, I show how to create either a modern site or communication site

   public class CommunicationSite
    {
        [Required]
        public string Title { get; set; }
        [Required]
        public string Url { get; set; }
        public string Description { get; set; }
        public string Owner { get; set; }
        //public bool AllowFileSharingForGuestUsers { get; set; }
        public uint Lcid { get; set; }
        public string Classification { get; set; }
        public string SiteDesign { get; set; }
        //...
    }

  public async Task<IHttpActionResult> CreateCommunicationSite([FromBody]CommunicationSite model)
        {
            if (ModelState.IsValid)
            {
                var tenant = await TenantHelper.GetActiveTenant();
                var siteCollectionStore = CosmosStoreHolder.Instance.CosmosStoreSiteCollection;
                await siteCollectionStore.RemoveAsync(x => x.Title != string.Empty); // Removes all the entities that match the criteria
                string domainUrl = tenant.TestSiteCollectionUrl;
                string tenantName = domainUrl.Split('.')[0];
                string tenantAdminUrl = tenantName + "-admin.sharepoint.com";

                KeyVaultHelper keyVaultHelper = new KeyVaultHelper();
                await keyVaultHelper.OnGetAsync(tenant.SecretIdentifier);
                using (var context = new OfficeDevPnP.Core.AuthenticationManager().GetSharePointOnlineAuthenticatedContextTenant(tenant.TestSiteCollectionUrl, tenant.Email, keyVaultHelper.SecretValue))
                {
                    try
                    {
                        CommunicationSiteCollectionCreationInformation communicationSiteInfo = new CommunicationSiteCollectionCreationInformation
                        {
                            Title = model.Title,
                            Url = model.Url,
                            SiteDesign = EnumHelper.ParseEnum<CommunicationSiteDesign>(model.SiteDesign),
                            Description = model.Description,
                            Owner = model.Owner,
                            AllowFileSharingForGuestUsers = false,
                            // Classification = model.Classification,
                            Lcid = model.Lcid
                        };

                        var createCommSite = await context.CreateSiteAsync(communicationSiteInfo);


                        return Ok();
                    }
                    catch (System.Exception ex)
                    {
                        throw ex;
                    }
                }
            }
            return BadRequest(ModelState);
        }

In the previous code, its important to see how we get the active tenant, because we need to know in which tenant to execute the operation, this method, is refactord into a helper class

  public static class TenantHelper
    {

        public static async System.Threading.Tasks.Task<SharepointTenant> GetActiveTenant()
        {
            var tenantStore = CosmosStoreHolder.Instance.CosmosStoreTenant;
            var tenant = await tenantStore.Query().Where(x => x.Active == true).FirstOrDefaultAsync();
            return tenant;
        }
    }
Create communication site UI

Here I show the component and how I call the endpoint to create a communication site. In the next and last post, I will show how to create these sites based on templates.

About the Author:

Luis Valencia, CTO at Software Estrategico, Medellin, Colombia, independent blogger and still a coder, after 17 years of experience in the field and regardless of my position, and mostly with SharePoint/Office Products, I still love to code, open Visual Studio and bring solutions to users and to the community its what makes me wake up every morning.

Feel free to contact me via twitter direct messages, @levalencia

Reference:

Valencia, L. (2019). Leveraging Office PnP Core to Create Communication Sites with Saved Page Templates. Available at: http://www.luisevalencia.com/2019/03/03/leveraging-office-pnp-core-to-create-communication-sites-with-saved-page-templates/ [Accessed 15th April 2019]

Share this on...

Rate this Post:

Share:

Topics:

PnP

Tags: