February 12, 2021

Azure SSO and Unlimited Access Control Customization

By Ali Atlasi

As part of a bigger project, I have been asked to implement SSO with Microsoft Azure aka Active Directory and build highly customizable Access Control on Sanity.io

In this guide I will try to explain the steps I took.

Playing with example projects on Github, I initially believed SSO implementation is a code part of Sanity studio. It took me two to three weeks to find out your SSO code is completely outside of Sanity.

First lesson, you cannot run NodeJS code within Sanity pre-login. It now sounds common-sense to me and a very secure approach, but took a while to understand.

Azure AD SSO

In my effort I was trying to inject this Microsoft OpenIDConnect-nodejs Sample project inside Sanity. We ended up putting this small invisible app on Azure App Service to keep it tidy under our Microsoft account and accessible by IT department.

The OpenID sample is very straightforward and gives you a Login button and after authentication it does a callback to a page with response including User profile fields such as Name, email, and Azure AD Group IDs.

The next step is just to modify the approved NodeJS app to remove the login page and the return page. So, when your Sanity login calls the app it goes straight to authentication and on return it goes straight to Sanity.

Follow the OpenIDConnect step by step guide. Test it to see whether it runs as a standalone app and that it authenticates and returns user details.

To replace the login just replace this part of app.js :

From

app.get('/', function(req, res) {
  res.render('index', { user: req.user });
});

To

app.get('/',
function(req, res, next) {
  passport.authenticate('azuread-openidconnect', 
    { 
      response: res,                      // required
      resourceURL: config.resourceURL,    // optional. Provide a value if you want to specify the resource.
      customState: 'my_state',            // optional. Provide a value if you want to provide custom state value.
      failureRedirect: '/' 
    }
  )(req, res, next);
},
function(req, res) {
 // log.info('Login was called in the Sample');
  res.redirect('/');
});

The next step is to modify the return.

From

// 'POST returnURL'
// `passport.authenticate` will try to authenticate the content returned in
// body (such as authorization code). If authentication fails, user will be
// redirected to '/' (home page); otherwise, it passes to the next middleware.
app.post('/auth/openid/return',
  function(req, res, next) {
    passport.authenticate('azuread-openidconnect', 
      { 
        response: res,    // required
        failureRedirect: '/'  
      }
    )(req, res, next);
  },
  function(req, res) {
    log.info('We received a return from AzureAD.');
    res.redirect('/');
  });

To (Remember we will change this later to include permissions/grants)

app.post('/auth/openid/return',
  function(req, res, next) {
    passport.authenticate('azuread-openidconnect', 
      { 
        response: res,                      // required
        failureRedirect: '/'  
      }
    )(req, res, next);
  },
  function(req, res) {
    const SESSION_LENGTH = 7 * 24 * 60 * 60 * 1000;
    const userId = hashUserId(req.user._json.name);

//console.log('We received a return from AzureAD.');
//console.log(req.user._json);

      fetch(
        `https://${config.creds.sanityProject}.api.sanity.io/v1/auth/thirdParty/session`,
        {
          method: 'POST',
          body: JSON.stringify({
            userId,
            userFullName: req.user._json.name,
            userEmail: req.user._json.preferred_username,
            userRole: 'editor',
            sessionExpires: new Date(new Date().getTime() + SESSION_LENGTH),
            sessionLabel: `SSO: ${req.user._json.name}`,
          }),
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${config.creds.sanityAPI}`,
          },
        },
      ) .then( r => r.json() )
      .then( data => {
       // console.log(data);
        const sanityTokenJson = data;
        const claim = sanityTokenJson.endUserClaimUrl;
      
        res.writeHead(302, {
          Location: `${claim}?origin=${config.creds.sanityStudioURL}`,
        });
        res.end();
      });

});

Just remember the user you are passing should be hashed and starts with e-

function hashUserId (username) {
  if (!username) {
    throw new Error('Username not found');
  }
  const hash = createHmac('sha256', config.creds.clientSecret)
    .update(username.toLowerCase())
    .digest('hex');

  // Sanity user ids must start with e-
  return `e-${hash}`;
};

To read more about generating Token please see Sanity doc here

For my own business rules I keep all SSO users as editor and for data protection I decided not to pass user profile images. I kept all the administrator users out of SSO and manage them using Sanity built-in User Management. Later I will explain how I manage the permissions for these editor users.

Congratulation, now you have an invisible app on Azure App Service that quietly authenticates users (who belong to your company) and generates Sanity Token and redirects them back to Sanity.

Before going further with Access Control, I have to say my dataset is private and I don't have "everyone" anywhere. I kept it as closed as possible.

Access Control

If you have already read Sanity Doc on Access Control you probably noticed there are lots of hardcoding of group names and permissions in code. Have a look at this and this before proceeding.

Hardcoding groups was no good for me. My client is broadcaster and Journalists fall into dozens of different categories each with very detailed permission requirements. I do not want to change the code every time the company has new Group roles. Also, I don't want to be the only one who can control these, So I needed a user-friendly interface that I can customise and Admins can access.

What's better than a dedicated Document Schema on Sanity itself.

//groups.js
import MdPerson from 'react-icons/lib/md/person'

export default {
  name: 'groups',
  type: 'document',
  title: 'Access Groups',
  icon: MdPerson,
  fields: [
{
  name: 'name',
  type: 'string',
  title: 'Group Name',
  validation: (Rule) => Rule.required(),
},{
  name: 'groupid',
  type: 'string',
  title: 'Azure Group ID',
  validation: (Rule) => Rule.required(),
},
{
  name: 'canpublish',
  title: 'Can Publish items',
  description:'If not turned ON the publish btn will be disable and content saves as DRAFT',
  type: 'boolean',
},
{
  title: 'Language Specific',
  name: 'language',
  type: 'string',
  options: {
    list: [
      {title: 'All', value: 'all'},
      {title: 'فارسی', value: 'farsi'},
      {title: 'عربی', value: 'arabic'},
      {title: 'English', value: 'english'}
    ]
  },
  validation: (Rule) => Rule.required(),
},
{
  title: 'Add Grants',
  name: 'grants',
  type: 'array',
  of: [{type: 'objgrant'}]
    }
  ],
  preview: {
    select: {
      title: 'name',subtitle: 'language'
    }
  }
}

objgrant in the Array of grants defined below:

//objgrant.js

import React from 'react'

import grants from './grants'

let grantsData = grants()
var filterGrants = []
for (var i in grantsData) {
  filterGrants.push(...[{title: `${grantsData[i]}`, value: `${grantsData[i]}`}]);
     
}

export default {
  name: 'objgrant',
title: 'Grants',
type: 'object',
fields: [
  {
    name: 'filter',
    type: 'string',
    title: 'Filter',
    options: {
      list: filterGrants
    }
  },
 {
   title: 'Permissions',
   name: 'permissions',
   type: 'array',
of: [{type: 'string'}],
options: {
 list: [
    {title: 'read', value: 'read'},
    {title: 'create', value: 'create'},
    {title: 'update', value: 'update'},
    {title: 'manage', value: 'manage'},
    {title: 'history', value: 'history'}
 ]
}
}]

}

I made a separate .js function that lists and contains all the document types within my studio. There might be easier way to just get an array of all the types but in my case, I just listed them:

//grants.js

'use strict'
module.exports = function grants() {
	return  [
		'story',
		'article',
		'supernews',
		'category',
		'author',
		
		... The rest of types

Tip:

As you've noticed I've lifted all my inline objects. That took me one or two days to figure out why my graphql deploy is giving error and had to lift inline objects mid project.

If you are planning to use graphQL don't use inline objects.

Tip:

If you don't enable 'history' permission, the users always get a tiny error msg. interesting this permission is not in the documentations. This permission enables the side panel 'review changes'.

Back to my groups document schema, I've put as many customised fields as I desired to use in various places and codes around my code.

Now you can have as many different groups you want with as many grants you want. Simply as a normal sanity document. You are not going to give anyone other than your administrators access to this document type in future. You can hide it on Desk too and your normal users won't know about its existence.

Note:

Avoid having space in group names, use "-" instead

Note:

I'm storing Azure Group ID in the document that acts as a key. Similar to foreign key in the SQL database designs.

Return to SSO

I'm going to use codes in this example in my SSO app. Adding Groups with permissions, Deleting Groups, adding users to groups.

We are now editing Azure NodeJS app app.js again.

Remember to define your client using API Token with Create Session rights. (Enterprise plan)

// Sanity Client Config here

const client = sanityClient({
  projectId: config.creds.sanityProject,
  dataset: 'production',
  token: config.creds.sanityAPI,
  useCdn: false,
});

I'm adding a set of Functions for different tasks below.

Creating New Empty Sanity Group

function createGroup(groupDoc) {
  client.createIfNotExists(groupDoc).then(res => {
    console.log(`Created or replaced system group ${res._id}`)
  })
};

function createSeperateGroup(groupname) {
  return {
    _id: `_.groups.${groupname}`,
    _type: 'system.group',
    grants: [],
    members: []
  }
};

Calling them using

//result[0].name comes from you Sanity Groups Document.
          createGroup(createSeperateGroup(result[0].name));

Removing All Groups on Sanity

If you are still testing and you want to remove all test groups before final implementation. Manual run, don't include in your workflow.

function removeAllGroup() {  
  for (let [key, value] of GroupArray) {
    //console.log(key + " - " + value);
    
    client.delete(`_.groups.${value}`)
    .then(res => {
      console.log('group deleted')
    })
    .catch(err => {
      console.error('Delete failed: ', err.message)
    })
    
  }
};

Adding User to Sanity Group

function addUserGroup(groupname,userId) { 
  client
  .patch(`_.groups.${groupname}`)
  .setIfMissing({ members: [] })
  .append('members', [userId])
  .commit();
};

Removing User from Sanity Group

function removeUserGroup(userId) { 
  //My own custom groups document type
  const query = '*[_type == "groups"]{name}'
  
  client.fetch(query).then(result => {
    groups.forEach(group => {
      console.log(group.name);
      
      client
      .patch(`_.groups.${group.name}`)
      .setIfMissing({ members: [] })
      .splice('members', members.indexOf(userId), 1, null)
      .commit();
    })
  })
};

as you can see in the above snippet, I've started using my own groups document type.

Now we are going to re-write the Azure OpenID return to add Access Control before SSO token and redirecting to Sanity studio.

app.post('/auth/openid/return',
  function(req, res, next) {
    passport.authenticate('azuread-openidconnect', 
      { 
        response: res,                      // required
        failureRedirect: '/'  
      }
    )(req, res, next);
  },
  function(req, res) {
    const SESSION_LENGTH = 7 * 24 * 60 * 60 * 1000;
    const userId = hashUserId(req.user._json.name);

//  removeAllGroup();
//console.log('We received a return from AzureAD.');
//console.log(req.user._json);

removeUserGroup(userId);

if(req.user._json.groups !=null){
  var hasGroup = false;
    for (let r of req.user._json.groups) {
      const query = '*[_type == "groups" && groupid==$groupid]{name,grants[]{filter,permissions}}'
      const params = {groupid: r}
      
      client.fetch(query, params).then(result => {
        if(result[0] !== undefined)
        {
          hasGroup = true;
          result[0].grants.forEach(grant => {
            grant.filter = `(_type == '${grant.filter}')`
          })
          createGroup(createSeperateGroup(result[0].name));
      
         // console.log(result[0].name);
          client
          .patch(`_.groups.${result[0].name}`)
          .setIfMissing({ grants: [] })
          .append('grants', result[0].grants)
          .commit();
          // Include user in group    
          addUserGroup(result[0].name,userId);   
        }
      }) 
      
    }
      fetch(
        `https://${config.creds.sanityProject}.api.sanity.io/v1/auth/thirdParty/session`,
        {
          method: 'POST',
          body: JSON.stringify({
            userId,
            userFullName: req.user._json.name,
            userEmail: req.user._json.preferred_username,
            userRole: 'editor',
            sessionExpires: new Date(new Date().getTime() + SESSION_LENGTH),
            sessionLabel: `SSO: ${req.user._json.name}`,
          }),
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${config.creds.sanityAPI}`,
          },
        },
      ) .then( r => r.json() )
      .then( data => {
       // console.log(data);
        const sanityTokenJson = data;
        const claim = sanityTokenJson.endUserClaimUrl;
      
        res.writeHead(302, {
          Location: `${claim}?origin=${config.creds.sanityStudioURL}`,
        });
        res.end();
      });
    
    
  }else{
    res.redirect('some other URL if user doesnt belong here');
  }


});

Note:

I'm removing the userID from all the groups and re-adding it on every login. In case Azure Group memberships of user has been changed on Active Directory, Or my customised Sanity document "groups" has been changed by administrator adding or removing grants.

Note:

In the Azure json response of User, you will have User's Azure groups membership as an array.

req.user._json.groups

User might have many groups in Azure and only some related to Sanity. I'm checking the returned Group array against my defined groups in sanity to check if I have document for each Azure Group ID by (this is not system group, this is my own group document schema)

const query = '*[_type == "groups" && groupid==$groupid]{name,grants[]{filter,permissions}}'

const params = {groupid: r}

The whole reading and reassigning users to groups is based on _type==groups custom document inside Sanity. This part of the code:

//Fetching each group in a loop
client.fetch(query, params).then(result => {

//If the Azure Group ID matches Azure Group ID in my Sanity document
        if(result[0] !== undefined)
        {
          hasGroup = true;
          
          //Turning the simple grants array in the Sanity document into functional grants array
          result[0].grants.forEach(grant => {
            grant.filter = `(_type == '${grant.filter}')`
          })
          
          //Create the Group if it's a new group name. Simply if it doesn't exist in system.group
          createGroup(createSeperateGroup(result[0].name));
      
         // console.log(result[0].name);
         
         //Appending the New/Existing system.group with all the new grants
          client
          .patch(`_.groups.${result[0].name}`)
          .setIfMissing({ grants: [] })
          .append('grants', result[0].grants)
          .commit();
          // Include user in group    
          addUserGroup(result[0].name,userId);   
        }
      }) 

Customizing the Desk

Once your Access Control is set, Users can login and they can still see the whole Desk Structure, except that the documents inside your structure are inaccessible/unavailable.

Rune helped me by giving me this example, I suggest you have a look at the example first.

In the example you have one very valuable line

* [_type == "system.group" && $identity in members] {_id}

Now we know $identity by itself returns the userID of the current User.

But to be honest I couldn't ever get * [_type == "system.group" && $identity in members] {_id} to work.

It only works if an 'administrator' or privileged user is logged in, not a SSO limited 'editor'.

My workaround was to have another API token with readOnly access and split the line into two calls.

export default () => client.fetch("$identity").then(result => {
  let userid=result; 
  //console.log(userid);

    return  sanclient.fetch(`*[_type == "system.group" && '${userid}' in members] {_id}`)
        // A fallback standard Desk structure
      .then(docs => docs.map(doc => doc._id.split('.').pop()))
      .then(groupNames => {

Note:

client is the base client

import client from 'part:@sanity/base/client'

sanclient is API Token based client

const sanityClient = require('@sanity/client')
const sanclient = sanityClient({
  projectId: 'yourprojectID',
  dataset: 'production',
  token: 'your new ReadOnly Token that can read everything that current user cant', 
  useCdn: false // `false` if you want to ensure fresh data
})

Note:

$identity only returns correct data on base client, not API client.

Note:

In the example you'll see that the desk structure is returned by

export default () => client.fetch(groupQuery)
  .then(docs => docs.map(doc => doc._id.split('.').pop()))
  .then(groupNames => {
    return S.list().title('Content').items(
      deskItems
    )
  })
  .catch(() => {
    return S.list()
      .title('Standard structure')
      .items([])
  })

There is nothing stopping you from having nested return. Something like this

export default () => client.fetch(groupQuery)
  .then(docs => docs.map(doc => doc._id.split('.').pop()))
  .then(groupNames => {
  //Do something with groupNames here, pass them to the next nest
  
    return client.fetch(AnotherQuery)
            .then(docs => docs.map(doc => doc._id.split('.').pop()))
            .then(groupNames => {
              return S.list().title('Content').items(
                deskItems
              )
            })
            .catch(() => {
              return S.list()
                .title('Standard structure')
                .items([])
            })
    
  })
  .catch(() => {
    return S.list()
      .title('Standard structure')
      .items([])
  })

Before continuing the rest of Desk Structure customization, Please have good read of this and familiarise yourself with nested structure. think of it as a nested Array that users see as Folders inside Folders.

import S from '@sanity/desk-tool/structure-builder'

export default () =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')
        ),
      ...S.documentTypeListItems()
    ])

For my customisation I simply turned these folders (arrays) into 1 parent array and separate arrays for each Folder/Nested array

const deskItems = []

        const EditorialdeskItems = []
        const FinancedeskItems = []
        const SiteSettingdeskItems = []
        const TVdeskItems = []

and later on, combine them into 1 array of deskItems

                if(EditorialdeskItems.length >0 ){
                  deskItems.push(...[
                    S.listItem()
                    // Give it a title
                    .title('Editorial')
                    .child(S.list().title('Editorial').items(EditorialdeskItems))
                  ])
                }
                
                if(SiteSettingdeskItems.length >0 ){
                  deskItems.push(...[
                    S.listItem()
                    // Give it a title
                    .title('Site Settings')
                    .child(S.list().title('Site Settings').items(SiteSettingdeskItems))
                  ])
                }

                if(TVdeskItems.length >0 ){
                  deskItems.push(...[
                    S.divider(),
                    S.listItem()
                    // Give it a title
                    .title('TV')
                    .child(S.list().title('TV').items(TVdeskItems))
                  ])
                }

                 if(FinancedeskItems.length >0 ){
                  deskItems.push(...[
                    S.listItem()
                    // Give it a title
                    .title('Finance')
                    .child(S.list().title('Finance').items(FinancedeskItems))
                  ])
                }
                
              if(WeatherdeskItems.length >0 ){
                deskItems.push(...[
                  S.listItem()
                  // Give it a title
                  .title('Weather')
                  .child(S.list().title('Weather').items(WeatherdeskItems))
                ])
              }
              
              if(AccessdeskItems.length >0 ){
                deskItems.push(...[S.divider(),AccessdeskItems[0]])
              }
                
                
                return S.list().title('Content').items(deskItems)

Now to illustrate how to dynamically add deskItems based on > UserID > GroupMembership > Grants matching GroupName > Inside our custom groups Sanity document.

export default () => client.fetch("$identity").then(result => {
  let userid=result; 
  //console.log(userid);

    return  sanclient.fetch(`*[_type == "system.group" && '${userid}' in members] {_id}`)
        // A fallback standard Desk structure
      .then(docs => docs.map(doc => doc._id.split('.').pop()))
      .then(groupNames => {
      
        const deskItems = []

        const FinancedeskItems = []
        const PublicMessagesdeskItems = []
        const AccessdeskItems = []

        //Narrowing down my Query to only groups this user has membership
        //Creating dynamic filter for the next query
        var filterGroups = ""
        for (var i in groupNames) {
          filterGroups = filterGroups + ` || name == "${groupNames[i]}" `;          
        }
        
        //console.log(groupNames);
              
              //Querying my custom Sanity document, not system.group
              return sanclient.fetch(`* [_type == "groups" && (1==2 ${filterGroups})]{'filter': grants[].filter}.filter`)
              .then(returnedGrants => {
                
                //Making One Array of the lists of Documents this user can see. combining all the types in multiple Groups this user is part of
                var filterNames = []
                for (var i in returnedGrants) {
                  Array.prototype.push.apply(filterNames,returnedGrants[i]); 
                }

                //Public Messages Folder
                 if (filterNames.includes('satellite')) {
                  PublicMessagesdeskItems.push(...[S.listItem().title('Satellite Info').schemaType('satellite').child(S.documentTypeList('satellite').title('Satellite Info'))])
                }
                if (filterNames.includes('radioinfo')) {
                  PublicMessagesdeskItems.push(...[S.listItem().title('Radio Info').schemaType('radioinfo').child(S.documentTypeList('radioinfo').title('Radio Info'))])
                }
                if (filterNames.includes('phoneinfo')) {
                  PublicMessagesdeskItems.push(...[S.listItem().title('Phone Info').schemaType('phoneinfo').child(S.documentTypeList('phoneinfo').title('Phone Info'))])
                }
                if (filterNames.includes('publicmessages')) {
                  PublicMessagesdeskItems.push(...[S.listItem().title('Public Messages').schemaType('publicmessages').child(S.documentTypeList('publicmessages').title('Public Messages'))])
                }
                if (filterNames.includes('timezones')) {
                  PublicMessagesdeskItems.push(...[S.listItem().title('Time Zones').schemaType('timezones').child(S.documentTypeList('timezones').title('Time Zones'))])
                }

               // Finance Folder
                if (filterNames.includes('currency')) {
                  FinancedeskItems.push(...[S.listItem().title('Currencies').schemaType('currency').child(S.documentTypeList('currency').title('Currencies'))])
                }
                if (filterNames.includes('cryptocurrency')) {
                  FinancedeskItems.push(...[S.listItem().title('CryptoCurrency').schemaType('cryptocurrency').child(S.documentTypeList('cryptocurrency').title('CryptoCurrency'))])
                }
                if (filterNames.includes('cryptodollar')) {
                  FinancedeskItems.push(...[S.listItem().title('CryptoDollar').schemaType('cryptodollar').child(S.documentTypeList('cryptodollar').title('CryptoDollar'))])
                }
                if (filterNames.includes('gold')) {
                  FinancedeskItems.push(...[S.listItem().title('Gold').schemaType('gold').child(S.documentTypeList('gold').title('Gold'))])
                }
                if (filterNames.includes('oil')) {
                  FinancedeskItems.push(...[S.listItem().title('Oil').schemaType('oil').child(S.documentTypeList('oil').title('Oil'))])
                }
                if (filterNames.includes('stock')) {
                  FinancedeskItems.push(...[S.listItem().title('Stock').schemaType('stock').child(S.documentTypeList('stock').title('Stock'))])
                }
   
                //Access Folder
                if (filterNames.includes('groups')) {
                  AccessdeskItems.push(...[S.listItem().title('Access Groups').schemaType('groups').child(S.documentTypeList('groups').title('Access Groups'))])
                }


                if(PublicMessagesdeskItems.length >0 ){
                  deskItems.push(...[
                    S.listItem()
                    // Give it a title
                    .title('Public Messages')
                    .child(S.list().title('Public Messages').items(PublicMessagesdeskItems))
                  ])
                }
                 
                if(FinancedeskItems.length >0 ){
                  deskItems.push(...[
                    S.listItem()
                    // Give it a title
                    .title('Finance')
                    .child(S.list().title('Finance').items(FinancedeskItems))
                  ])
                }
                
              if(AccessdeskItems.length >0 ){
                deskItems.push(...[S.divider(),AccessdeskItems[0]])
              }
                
                
            return S.list().title('Content').items(deskItems)
            })

        })
      })
      .catch(() => {
        // In case of any errors fetching the groups, just return some standard
        // structure. This will only happen if the query cannot be performed for
        // some reason.
        return S.list().title('Content').items([])
      })
})

Note:

I'm querying my custom groups document using API Token client. The base client does not have access to this document nor the user. The Desk Structure only Reads it behind the scene to present the correct structure.

Note:

I think there are ways to make the DeskStructure more dynamic too. Creating a document schema to hold the Structure. By that I mean the Folder Names and what document schema goes into what folder. What you see here is still hardcoding the structure that I'm not a fan. But I over used my brain so far to get this far. I will tweak this after a nice holiday post-lockdown.

Note:

To make desk generation faster, I did a tweak to keep DeskItems[] outside of queries.

const deskItems = []

export default () => client.fetch("$identity").then(result => {
  let userid=result; 
  //console.log(userid);
    if(deskItems.length>0)
    {
      return S.list().title('Content').items(deskItems)
    }

    return  sanclient.fetch(`*[_type == "system.group" && '${userid}' in members] {_id}`)
        // A fallback standard Desk structure
      .then(docs => docs.map(doc => doc._id.split('.').pop()))
      .then(groupNames => {
              
        console.log("Generating Desk");

        const EditorialdeskItems = []
        const FinancedeskItems = []
        const SiteSettingdeskItems = []

Finally

make sure you add your Azure app to the login default-login.json by

{
  "providers": {
    "mode": "append",
    "redirectOnSingle": false,
    "entries": [
      {
        "name": "custom-login",
        "title": "Name of your company",
        "url": "https://Your Azure App URL.azurewebsites.net",
        "logo": "static/favicon.ico"
      }
    ]
  }
}

Conclusion

My aim to write this post was to give you ideas, snippets, and some hard to find information that might be easy for Sanity developers and difficult for first-timers.

As someone who pitched Sanity to my company, I wished Azure SSO and User groups integrations were easier, but the positive point is: now I can customise it however I want and however you want.

If you are going to use Azure AD for a company infrastructure, you still have some good challenges with Azure AD, Policies and App Service that are not Sanity.io related.

Please feel free to contact me if you are thinking of making something similar and might need help of someone who has done it before.

Wishing you health and happiness

λ npm install -g @sanity/cli
λ sanity init
Get started for free