Tag: MicrosoftGraph

New granular permission model in SharePoint

New granular permission model in SharePoint

Recently Microsoft unveiled the new granular permission model in SharePoint targeting the access of Microsoft Graph towards resources such as lists, libraries, folders and items.

This post shows how to quickly set it up and a walk through how it works.

Content

Setup

For the setup first two application registrations are needed. One is the ‘administrative’ one that will later assign permissions. The second one is used to access the resources in case permissions are granted.

Assuming you are familiar setting up application registration in general here only the api permissions will be considered:

  1. On application registrations 1 delegated Sites.FullControl.All or Sites.Selected + Owner is needed
  2. On application registrations 2 start with Lists.SelectedOperations.Selected
    This will handle access to the whole list
  3. As the application registration permission only works in combination with the resource applied to, the next permission can already be applied, too, ListItems.SelectedOperations.Selected

Two things to note here: First everything that comes now will be in delegated mode. Second as known from my previous Resource specific consent (RSC) usages no access is given so far. This will be done now in the following two steps for lists and list items.

The new granular *.Selected permissions
The new granular *.Selected permissions

Demo List permissions

Assuming the right *.Selected permissions given to an application registration it simply has to be assigned to the permissions endpoint of the given resource here a list:

POST https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/permissions/

The request body simply looks like this;

{
    "roles": [
        "write"
    ],
    "grantedTo": {
        "application": {
            "id": "16b9ff16-2dfd-4953-81e7-c5e5a63376e2"
        }
    }
}

After the permission is granted, a test shows that the list but also all items can be accessed:

GET https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/
GET https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/items/

For the negative test and to move to the next section the granted permission needs to be revoked. This will be done with a simple delete request on the permission ID which sightly looks like this:

DELETE https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/permissions/aTowaS50fG1zLnNwLmV4dHw2ZjA5MGM0YS1lNjg3LTRhYjktOWJjZi1hZTQwYTk0OWEwMzlANWJmM2VlNWItOTVkZC00ZDJjLTkxYWMtMWVmOWE2MDY2ODA1

After the permission is revoked again any access to the list or its items are answered by a 404.

Demo List Item permissions

The next thing to do is grant permissions on specific list items for example those with id 1 and 3:

POST https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/items/1/permissions
POST https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/items/3/permissions

While the endpoint is naturally slightly longer than above the request body totally stays the same.

After the permissions are granted, a test shows that the list but also the granted/selected items only can be accessed:

GET https://graph.microsoft.com/beta/sites/bca9b232-752e-4710-ba18-533e63a00d25,cfb643a4-8f68-4b8e-925e-c8f2c3544d4d/lists/48f446fb-0d60-48a7-b9d2-f4ddeafa588d/items/

Several things to add after this walk-through. As seen in the URL endpoints this technique is still in beta. It also only works with the usage of the Graph API (no SP Rest, CSOM e.g) of course. Furthermore, there is another 3rd permission not demonstrated here: Files.SelectedOperations.Selected. While the shown ListItems.SelectedOperations.Selected operates on sole list items but also its corresponding documents if applicable, Files.SelectedOperations.Selected only works on documents. Finally, as well known, folders can be be treated as list items, too.

This was the theoretical walk through to the process of the new granular permission model in SharePoint. For your own reference I created a small GitHub repository where you can test your own scenario based on .net or PowerShell.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Calling Microsoft Graph in SPFx the secure way

Calling Microsoft Graph in SPFx the secure way

As mentioned in many, many posts Microsoft Graph but also others 3rd party APIs become more and more essential to be called from SharePoint Framework. In this post I want to highlight the potential most secure way this should be done and I will also argue why after a step-by-step description how to set it up client and server side.

As most of the parts and especially the Magic happens server-side the post will start with that part. The consuming client part will take place at the end of this post.

Content

Setup Consuming App registration

A basic app registration needs to be created and the typical values like clientId, tenantId and clientSecret are stored. No need to mention that in production scenarios or even in shared developer scenarios they should not be shared in the application settings like done in this simple sample.

To access Microsoft Graph from inside the AzureFunction 2 more essential things are needed. These are given delegated Graph permissions and a valid “Application ID URI” used as Audience to be verified.

At first authentication needs to be done. Therefore, a bearer token should be in place at the HTTP request’s header. This must be validated. How to do that is nearly taken from this Microsoft Graph sample.

The token itself is generated client-side from SPFx (see below).

Basically, it just needs to be a bearer token. But in the sample it is validated against a lot of attributes such as clientID, a tenantID SigningKey, ValidIssuer, ValidAudience e.g. Also a check against extended token attributes such as Entra ID group membership might make sense.

public async Task<string>ValidateAuthorizationHeaderAsync(Microsoft.Azure.Functions.Worker.Http.HttpRequestData request)
{
  if (request.Headers.TryGetValues("authorization", out IEnumerable<string>? authValues))
  {
    var authHeader = AuthenticationHeaderValue.Parse(authValues.ToArray().First());
    if (authHeader != null && string.Compare(authHeader.Scheme, "bearer", true, CultureInfo.InvariantCulture) == 0 && !string.IsNullOrEmpty(authHeader.Parameter))
    {
      var validationParameters = await GetTokenValidationParametersAsync();
      ...
private async Task<TokenValidationParameters?> GetTokenValidationParametersAsync()
    {
      if (_validationParameters == null)
      {
        var tenantId = _config["tenantId"];
        var clientId = _config["clientId"];
        var domain = _config["domain"];
        var configManager = new ConfigurationManager<OpenIdConnectConfiguration>($"https://login.microsoftonline.com/{tenantId}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
        var config = await configManager.GetConfigurationAsync();
        _validationParameters = new TokenValidationParameters
        {
          IssuerSigningKeys = config.SigningKeys,
          ValidateAudience = true,
          ValidAudience = $"api://{domain}/{clientId}",
          ValidateIssuer = true,
          ValidIssuer = config.Issuer,
          ValidateLifetime = true
        };
      }
      return _validationParameters;
    }

Next step is the authentication against Microsoft Graph. Theoretically, this can be done in two ways.

We need some values such as a clientID, a clientSecret or a tenantID. While those should be stored in a secure content storage. At least secrets should be stored in an Azure Key Vault or similar. Other IDs could be stored in an Azure App Configuration Service.

On-behalf-flow

The on-behalf-flow takes the bearer token from the last step and creates a new access token based on a client ID and secret to access Microsoft Graph on behalf of the current user.

public GraphServiceClient? GetUserGraphClient(string userAssertion)
{
  var tenantId = _config["tenantId"];
  var clientId = _config["clientId"];
  var clientSecret = _config["clientSecret"];
  var scopes = new[] { "https://graph.microsoft.com/.default" };
  var onBehalfOfCredential = new OnBehalfOfCredential(tenantId, clientId, clientSecret, userAssertion);
  return new GraphServiceClient(onBehalfOfCredential, scopes);
}

The bearer token from the last step here is also called the userAssertion. It is the User Representation compared to the app and tenant representation.

Client-credentials-flow

This is the elevated app permissions variant. Theoretically, there’s no need to authenticate before at the Function level of course. But as this is the more risky one, any focus should be taken on this.

Nevertheless, as this blog post is called “the secure way” it will concentrate on the user scope which should always be preferred.

Graph Operation

As simple user operation with overwrite the description of the given site. At first the the right GraphServiceClient needs to be established (see above). The rest is pretty straightforward.

public async Task<bool> UpdateSiteDescreption(string userAssertion, string siteUrl, string newSiteDescreption)
{
  _appGraphClient = GetUserGraphClient(userAssertion);
  Uri uri = new Uri(siteUrl);
  string domain = uri.Host;
  var path = uri.LocalPath;
  var site = await _appGraphClient.Sites[$"{domain}:{path}"].GetAsync();
  var newSite = new Site
  {
    Description = newSiteDescreption
  };
  try
  {
    await _appGraphClient.Sites[site.Id].PatchAsync(newSite);
  }
  catch (Microsoft.Graph.Models.ODataErrors.ODataError ex)
  {
    _logger.LogError(ex.Message);

First the given site is requested. Then a new SiteBuilder is created with the fields to be updated and finally it’s patched against the given site.

Although this seems a less sensitive operation it’s a good example for „security“ as even for such a harmless operation Sites.FullControl.All permission is required. And it’s much better to limit it to one app / function instead of providing it to all (including future) SPFx web parts.

User permissions vs delegated Sites.Selected scope

In my last post, I explained Sites.Selected dedicated scope. But the simpler way might be to use user permissions combined with sites access. So what’s the difference here?

It’s that simple: One additional step!

In the simpler scenario the Function can do what the user is allowed to and the app is allowed to. With the delegated scope the user can do what he is allowed to and the app together with permissions given on any dedicated site.

Azure Function considerations

Set authentication

Authentication for Azure Functions should be set up on the resource base as shown in the following picture:

Authentication for Azure Function, choose provider
Authentication for Azure Function

As shown we can use the already established application registration for this.

CORS

To support CORS (Cross-origin resource sharing) two levels need to be considered. At first a local debug scenario and second the official client-server connection. Locally this needs to be configured in the app settings while client server it will be configured in the resource settings.

{
  "Values": {
     ...
  },
  "Host": {
    "CORS": "*"
  }
}

This is how it looks like locally while on the Azure resource it looks like this

CORS setting in the Azure resource

Consuming web part

The consuming web part is a simple one providing a given url and the new description. It transports the values server-side while authenticating only user_impersonation together with AadHttpClient and inside the AzureFunction it’s decided who is able to do what/more. This is what I call “the secure way“.

So only a quick look on the web part. Of course there is a need for a ui providing inputs for URL and new description. Additionally, there must be a button to execute the function and that’s it. Behind the scenes the function looks quite like this.

The web part to update the site description
The web part

The most interesting part potentially takes place after the button is pressed. Because the authentication against the backend Azure Function needs to take place and the content needs to be transported.

export default class FunctionService {
  private aadHttpClientFactory: AadHttpClientFactory;
  private client: AadHttpClient;
  public static readonly serviceKey: ServiceKey<FunctionService> =
    ServiceKey.create<FunctionService>('react-site-secure-function-call-smpl', FunctionService);
  constructor(serviceScope: ServiceScope) {  
    serviceScope.whenFinished(async () => {
      this.aadHttpClientFactory = serviceScope.consume(AadHttpClientFactory.serviceKey);      
    });
  }
  public async setNewSiteDescreption(siteUrl: string, siteDescreption: string): Promise<any[]> {
    this.client = await this.aadHttpClientFactory.getClient('api://xxx.azurewebsites.net/0a8dfbc9-0423-495b-a1e6-1055f0ca69c2');
    const requestUrl = `http://localhost:7241/api/SiteFunction?URL=${siteUrl}&Descreption=${siteDescreption}`;
    return this.client
      .get(requestUrl, AadHttpClient.configurations.v1)   
      .then((response: HttpClientResponse) => {
        return response.json();
      });
  }
}

Based on the aadHttpClientFactory and the ServiceKey pattern in the update method a client is established using the audience (see above) and finally a get request (in this case against a local host debug) is executed.

Summary

So what are the big advantages of this way? The first thing of course is, this way does not allow any uncontrolled things from any client/web part to serve side. No Microsoft Graph access is directly given to a public enterprise application. Only an access to an Azure function is provided. And this Azure function (incl. access to it!) is controlled by the developer. An exclusive application registration for the Azure Function controls and restricts this independently from other solutions.

Diagram: Usage of MsGraphClient vs AadHttpClient
MsGraphClient vs AadHttpClient

For further reference on the whole solution, please refer to the corresponding GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Using SharePoint Framework (SPFx) to assign delegated scope permissions to a site

Using SharePoint Framework (SPFx) to assign delegated scope permissions to a site

A fellow MVP colleague recently published a blog post explaining the new resource specific consent with delegated scope. One benefit here is that there is no need for tenant admin similar application permission (app scope Sites.FullControl.All) to apply resource specific permission. Only site collection administration permissions are needed anymore. Martin describes, how to do that with script code. But what about enterprise scenarios were there is no script available? Even not for administrators, have seen this a lot a in the past.

This post will describe a solution (code)based on SharePoint framework (SPFx) to do the same thing. It will also describe the pros and cons of each approach.

Content

Configure Application

The application registration should be configured in the web part properties. As there might be many application registrations, it might make sense to filter them on the pre-fix which is done in the sample with “dlg”.

Web part configuration incl App registration and use admin mode
Web part configuration

Select site or use current one

Another option the web part can be configured on is either use the current site or in admin mode search for another site using the Graph search api endpoint.

If the web part is configured in admin mode an additional search field is shown where the user can enter text and then search for matching sites. From that match result the user can pick one which is then used as the actual one to deal with.

Detect Site Collection Administrator access

Select a site from a search result to apply permissions to
Select a site from a search result

As said above, to apply permissions the acting user needs site collection administrator access. To evaluate this, the hidden User Information List can be taken. Inside, a field isSiteAdmin=true exists.

public async isSiteAdmin(userEMail: string, currentSiteId: string): Promise<boolean> {
    this.client = await this.msGraphClientFactory.getClient('3');
    const response = await this.client
            .api(`sites/${currentSiteId}/lists/User Information List/items`)
            .version('v1.0')
            .header('Prefer','HonorNonIndexedQueriesWarningMayFailRandomly')
            .expand('fields($select=EMail,IsSiteAdmin)')
            .filter(`fields/EMail eq '${userEMail}'`)
            .get();
    return response.value[0].fields.IsSiteAdmin;
 }

Every time the site in terms of ID is changed this evaluation method needs to be called again. The result will also be shown in the “Apply Permissions” button, which will only be enabled when the result is true (and in web part properties an application registration is picked btw).

The’ function to detect the site admin capability is included in the GraphService and evaluated from the site’s hidden “User Information List“. Notice needs to be taken on the custom fields which need to be expanded. A filter can only be set if a column is indexed which is not recommended for a system list like “User Information List” or in the Header it’s included Prefer: HonorNonIndexedQueriesWarningMayFailRandomly

Apply permissions

Apply permissions can be called once a site is selected or use current one is set and the user has permission to do so. The function to be called is inside the GraphService.

public async grantPermissions(role: string, appId: string, displayName: string, siteId: string): Promise<any[]> {
    this.client = await this.msGraphClientFactory.getClient('3');
    const requestBody = {
      roles: [
        role
      ],
      grantedToIdentities: [
        {
          application: {
            id: appId,
            displayName: displayName
          }
        }
      ]
    };
    const response = await this.client
            .api(`/sites/${siteId}/permissions`)
            .version('v1.0')    
            .post(requestBody);
    return response;
}

The function is pretty straightforward. From the arguments it constructs a body and that body is posted towards the site permissions.

“Consume” the access

As Martin already mentioned in his blog there are not much simple script samples to show the access functionality. This is because only Microsoft Graph access is enabled. CSOM for instance does not work. A simple sample you can see in Martin s blog and another one I will soon add to this repository based on an Azure Function accessing a site on behalf of Microsoft Graph.

Pros & Cons

While this web part seems to be a comfortable thing, this simplified version has a major disadvantage, that is the request for Sites.FullControl.All. This relatively high privileged permission is valid then for all web parts in the same tenant using MsGraphClient. I clearly recommend this only to be treated as a sample, especially when using such a solution in enterprise scenarios. A better solution would be to use it in an AzureFunction in the backend. For sure a bit more effort but worth it.

So why do you come up with this half valid sample you may ask? Well it’s here to explain most valid things and to not make it too complicated. I will come up with another version combining an SPFx web part with an AzureFunction soon. This might also include an Azure Function sample consuming the “access”.

Stay tuned but meanwhile for the whole code reference here is my GitHub repository.

Meanwhile there is a GitHub repository alternative using backend Azure Function.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Meeting feedback with Microsoft Teams Meeting App and Teams Toolkit for Visual Studio (C#)

Meeting feedback with Microsoft Teams Meeting App and Teams Toolkit for Visual Studio (C#)

In the past, I already created a Teams meeting app collecting feedback at the end of a meeting with a bot. I also, a while ago, started to transform some of my Teams app samples to Visual Studio and .Net versions on behalf of the new Teams Toolkit. So let’s try another one. This will illustrate a Teams Toolkit for Visual Studio and .Net Meeting lifecycle bot catching or mentioning several lifecycle events of a Teams meeting.

Content

Setup

At the time of writing this post there’s no clear documentation nor a learning path for setting up a bot solution with Teams Toolkit for Visual Studio 2022. Nevertheless, the set up is quite easy. A Setup for a new bot solution and that’s it. Some things like “Hello World Card” stuff can be get rid of and then for attending a Teams meeting some requirements need to be done.

In the manifest the following settings are necessary:

"validDomains": [
"${{BOT_ENDPOINT}}"
],
"webApplicationInfo": {
"id": "${{BOT_ID}}",
"resource": "https://RscBasedStoreApp"
},
"authorization": {
"permissions": {
"resourceSpecific": [
{
"name": "OnlineMeeting.ReadBasic.Chat",
"type": "Application"
}
]
}
}
}

This looks a bit different than in my “old” NodeJS based post but this is due to a change beginning with teams manifest schema 1.12. Nevertheless, it still enables the bot “get control over the chat” with Read access which enables to catch the lifecycle events. As a member of the chat the bot can then also post/update (it’s own) posts cause added to the chat as member. The webApplicationInfo is to establish permissions to the meeting’s chat “get control over the chat” to catch the lifecycle events.

Request feedback (once)

Request Card to vote - sent by the bot
Request Card to vote – sent by the bot

What is needed to request feedback from a meeting can be invoked at two times. Here is a very small piece of code which is executed when the meeting starts, a small text return to the meeting’s chat. But in the end this is not used within the sample.

protected override async Task OnTeamsMeetingStartAsync(Microsoft.Bot.Schema.Teams.MeetingStartEventDetails meeting, Microsoft.Bot.Builder.ITurnContext<Microsoft.Bot.Schema.IEventActivity> turnContext, System.Threading.CancellationToken cancellationToken)
{
  await turnContext.SendActivityAsync("Meeting started");
}

In the case of the this sample, the meeting end is important to catch. At this point of time the bot shall return an adaptive card, which shall enable the user to vote on the scale of 1-5 represented by emoji icons.

protected override async Task OnTeamsMeetingEndAsync(Microsoft.Bot.Schema.Teams.MeetingEndEventDetails meeting, Microsoft.Bot.Builder.ITurnContext<Microsoft.Bot.Schema.IEventActivity> turnContext, System.Threading.CancellationToken cancellationToken)
{
  AdaptiveCardsConroller adc = new AdaptiveCardsConroller(_hosturl);
  IMessageActivity initialCard = adc.GetInitialFeedback(meeting.Id);
  await turnContext.SendActivityAsync(initialCard);
}

Next comes the construction of the initial adaptive card. This is done by an own service because more constructed card versions are needed after feedback is (partially) given.

{
  "type": "AdaptiveCard",
  "schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "refresh": {
    "action": {
      "type": "Action.Execute",
      "title": "Refresh",
      "verb": "alreadyVoted",
      "data": {
        ....
      }
    },
    "userIds": "${votedPersons}"
  },
"body": [
  {
      "type": "TextBlock",
      "text": "How did you like the meeting?",
      "wrap": true
  },
  {
    "type": "ActionSet",
    "actions": [
      {
        "type": "Action.Execute",
        "title": " ",
        "verb": "vote_1",
        "iconUrl": "${_hosturl}/images/1.png",
        "data": {
          "meetingID": "${meetingID}",
          "votedPersons": "${votedPersons}",
          "votes1": "${formatNumber(votes1,0)}",
          "votes2": "${formatNumber(votes2,0)}",
          "votes3": "${formatNumber(votes3,0)}",
          "votes4": "${formatNumber(votes4,0)}",
          "votes5": "${formatNumber(votes5,0)}"
        }
      },
… 

First important thing here is to use at least version 1.4 to have a chance for using the Adaptive Cards Universal Action Model at a later point of time. It’s represented by the “refresh” part which is shortened here and explained later.

What is more important here is the option to vote. This is realized by an ActionSet where only the first one is shown for brevity and represented by Action.Execute with a verb once again. This can be caught inside the code while returning to the bot on giving feedback.

In Code on bot side this arrives in OnAdaptiveCardInvokeAsync. Here all necessary information are contained inside turnContext or directly invokeValue. Compared to the NodeJS version it’s a bit complex to convert that to a JSon object. Having that done based on the verb, it’s possible to detect if it’s another vote or a simple refresh from a user that are already voted.

If a vote is requested two things need to happen. Corresponding on the number of the vote (the scale 1-5) correct vote needs to be increased by one. Additionally, the current userID (AadObjectId) needs to be added to the userIDs of the card so the user is not able to vote again. In code this looks like this:

protected override async Task<AdaptiveCardInvokeResponse> OnAdaptiveCardInvokeAsync(ITurnContext<IInvokeActivity> turnContext, AdaptiveCardInvokeValue invokeValue, CancellationToken cancellationToken)
{
  string dataJson = invokeValue.Action.Data.ToString();
  Feedback feedback = JsonConvert.DeserializeObject<Feedback>(dataJson);            
  string verb = invokeValue.Action.Verb;
  if (verb == "alreadyVoted") { }
  else
  {
  switch (verb)
  {
    case "vote_1":
      feedback.votes1 += 1;
      break;
    case "vote_2":
      feedback.votes2 += 1;
      break;
                    ....
  }
  List<string> voters = new List<string>(feedback.votedPersons);
  voters.Add(turnContext.Activity.From.AadObjectId);
  feedback.votedPersons = voters.ToArray();              
  IMessageActivity deativatedCard = adc.GetDeactivatedFeedback(feedback);
  deativatedCard.Id = turnContext.Activity.ReplyToId;
  await turnContext.UpdateActivityAsync(deativatedCard);

Display feedback

Voting Result Card - sent by the bot
Voting Result Card – sent by the bot

What’s needed is a card that checks on rendering if the user already voted or not. If so, the card should be displayed with the overall result to the user instead of another possibility to vote again. The technology behind this is called Adaptive Cards Universal Action Model. To achieve this, first the adaptive card needs a “refresh” part. Known from above it was part on top (now in detail):

"refresh": {
  "action": {
    "type": "Action.Execute",
    "title": "Refresh",
    "verb": "alreadyVoted",
    "data": {
      "meetingID": "${meetingID}",
      "votedPersons": "${votedPersons}",
      "votes1": "${formatNumber(votes1,0)}",
      "votes2": "${formatNumber(votes2,0)}",
      "votes3": "${formatNumber(votes3,0)}",
      "votes4": "${formatNumber(votes4,0)}",
      "votes5": "${formatNumber(votes5,0)}"
    }
  },
  "userIds": "${votedPersons}"
}

Additionally, the card in the body only has a column set rendering the same icons we had in the action buttons before but now as images and together with the # of votes taken from the data object. That’s all.

The refresh part is a (not “really” visible) action in the card. It’s executed if the current user is part of the “userIds” and to be identified in the backend by the bot, a specific “verb” needs to be given. Coming back to the bot it can be identified inside invokeValue.Action.Verb:

if (verb == "alreadyVoted")
{
  if (feedback.votedPersons.Contains(turnContext.Activity.From.AadObjectId))
  {
    AdaptiveCardsController adc = new AdaptiveCardsController(_hosturl);
    IMessageActivity deativatedCard = adc.GetDeactivatedFeedback(feedback);
    deativatedCard.Id = turnContext.Activity.ReplyToId;
    await turnContext.UpdateActivityAsync(deativatedCard);
  }
  else
  {
    // User did not vote, yet
    IMessageActivity currentCard = adc.GetCurentFeedback(feedback);
    currentCard.Id = turnContext.Activity.ReplyToId;
    await turnContext.UpdateActivityAsync(currentCard);
  }                
}
else
{
    // See block above
}

The userId is checked once again (cause anyone can hit “refresh card”! See actions in the context menu of the card) and if it’s verified the user already voted, it will return another deativatedCard. This card once again is generated by Templating and from the AdaptiveCardsController.

Finally interesting is the part what happens if a user reaches the “refresh” part that did not vote, yet. How can this happen? How to detect? What to return?

Not everyone will vote at the exact same time. So it always needs to be detected if the calling user already voted. This can be found in either the data votedUsers or the userIds by checking aadObjectId. And then a “current” card needs to be returned: Still able to vote but with the current data (votes, userIds that already voted).

This also makes clear where the data is stored, it’s in the adaptive card itself. So in the end, the return is quite simple. If the user already voted a deactivated card, showing the result values shall be returned. If the user did not vote, yet, detected by the user ID not contained in the adaptive card’s data the user shall be still able to vote. But the card needs to hold the current data (not visible, but in the background for keeping always up to date during the next roundtrip to the bot).

And while checking during debug time, it becomes clearly detectable, the refresh run is executed once any user displays that part of the chat where the adaptive card is visible…

Debug temporary Dev Tunnel URL

In the wwwRoot folder there are the icons to be used by the adaptive cards. They are by the way also a good debug option if not sure if the bot is reachable by the URL. Simple usage of the Azure host or the temporary Dev tunnel (see dev.local. ) /images/1.png helps.

But how to deal with that while debugging in Visual Studio? A short explanation how a temporary dev tunnel is set: On every start of Visual Studio a new one is created. This also brings the need to “prepare teams app dependencies” via Teams Teams Toolkit afterwards. Now there is a system environment variable VS_TUNNEL_URL which can be caught in the program.cs on every start and put to the configuration instead of what will be there later in a hosted environment (a https://localhost:5130 e.g.).

if (app.Environment.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
  builder.Configuration["BotEndpoint"] = Environment.GetEnvironmentVariable("VS_TUNNEL_URL");
}

Having that config value it can be used for later creating the card with the icons for instance as also mentioned for manually URL testing above.

protected string _hosturl;
public TeamsBot(IConfiguration config)
{
  _appId = config["MicrosoftAppId"];
  _appPassword = config["MicrosoftAppPassword"];
  _hosturl = config["BotEndpoint"];
}

That’s it. This post showed a practical sample of a Teams Meeting app handling the meeting lifecycle with a bot. And in action it looks like this:

For further reference the whole sample is also available in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Demystifying Teams creation with resource specific consent (RSC) in Microsoft 365

Demystifying Teams creation with resource specific consent (RSC) in Microsoft 365

Automatic provisioning processes of SharePoint sites Microsoft 365 groups or nowadays Microsoft 365 Teams have a long history. Security was always a concern but with evolving ZeroTrust scenarios it becomes more important than ever. Automatic processes usually run unattended so access to all resources are a regular requirement but not really wanted. In this post I want to dig into the capabilities Microsoft Teams has for the scenario to allow unattended creation AND maintenance on specific groups of Teams but not on all of them. This is called resource specific consent (RSC).

Series

Content

Although resource specific consent for Microsoft Teams is not very new and even available before SharePoint adopted that technology (which I already wrote about by the way) since arriving of this there was not much written about. So let’s try to make this much more popular here.

Create Teams

As seen in the last part there are two options to create Teams: Create Group first option and directly create Teams option. Now that works the same here? And what is now the best one? Does it change? As seen from the last part only for creation of Teams no critical permissions are needed, especially when only users shall be owners or members so this simplified scenario will be used moving forward.

And as there is a need to work with a full Team, the way of creation doesn’t really matter here. So the debate if this or that direction in creation is better, can be left in the last post as well for the moment.

Application registrations

For more granular permissions a distinction between creation and maintenance process shall be taken. So at least two application registrations are needed.

  • App registration for creation (incl “teamify” and initial owner/member adding)
    • Secret or Self-signed certificate needed, Group.Create Team.Create User.Read.All (Directory.ReadWrite.All) TeamsAppInstallation.ReadWriteAndConsentForTeam.All
  • One or more App registration(s) for running maintenance processes on (category of) Teams
    • Secret or Self-signed certificate needed, NO Graph permissions

As unattended application mode is used for calls, a secret or self-signed certificate is needed to generate an access token. For the first application permissions need to be granted so groups 1st + teamify or teams directly can be created.

The latter application registration so far does not need any Microsoft Graph permissions directly at all.☝🏻 But the app id is used at several positions in later steps so to follow it a “special masked” value already introduced here: XXxxXXXXX-XxXX-xXXX-XXxx-XXXXXXXxxxXX

In a next step there might/should be the need to grant administration privileges to a specific team or a category or a bunch of them.

While in SharePoint this from the beginning worked directly (I described that scenario a while ago) the permissions needed to grant consent from one highly privileged app/user to specific Sites were unacceptable high with Sites.FullControl.All

In Teams this works a bit different. There is no only 1:1 relationship between a Team and an app registration from Entra ID anymore. “In the middle” there is also a Teams application now and this, on Installation, is responsible to grant required but resource specific permissions ONLY ☝🏻 on the the Team where installed.

The Difference between resource specific consent RSC in SharePoint and Microsoft Teams (state 2023)​
Difference between RSC in SharePoint and Microsoft Teams (2023)

The difference seems visually clear? So what about a little walkthrough?

A simple Teams App with the right but bare minimum security requests in the manifest is needed and should be uploaded to the org app catalog. Here is a simple bare minimum teams app manifest for this which can be used for that without any “real app parts” such as bots, static or configurable tabs e.g.

{
  "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
  "manifestVersion": "1.16",
  "version": "2.0.0",
  "id": "90eedbf6-dc55-4b98-a848-e48719266134",
  "packageName": "com.microsoft.teams.extension",
  "developer": {
    "name": "Teams App, Inc.",
    "websiteUrl": "https://www.example.com",
    "privacyUrl": "https://www.example.com/privacy",
    "termsOfUseUrl": "https://www.example.com/termsofuse"
  },
  "icons": {
    "color": "color.png",
    "outline": "outline.png"
  },
  "name": {
    "short": "MyTeamsMaintenance",
    "full": "Full name for MyTeamsApp5"
  },
  "description": {
    "short": "Short description of MyTeamsApp5",
    "full": "Full description of MyTeamsApp5"
  },
  "webApplicationInfo": {
    "id": "XXxxXXXXX-XxXX-xXXX-XXxx-XXXXXXXxxxXX",
    "resource": "https://RscBasedStoreApp"
  },
  "authorization": {
    "permissions": {
      "resourceSpecific": [
        {
          "name": "TeamSettings.ReadWrite.Group",
          "type": "Application"
        }
      ]
    }
  }
}

The id can be useful to retrieve a tenant-individual app catalog id which is necessary for later steps. See a call for this or an alternative in the next step.

Get app id from catalog (tenant individual!):

https://graph.microsoft.com/v1.0/appCatalogs/teamsApps?$filter=distributionMethod eq 'organization' and displayName eq 'MyTeamsMaintenance'

OR by the ID from App Manifest (unindividual from tenant)

https://graph.microsoft.com/v1.0/appCatalogs/teamsApps?$filter=externalId+eq+'90eedbf6-dc55-4b98-a848-e48719266134'

"value": [
{
"id": "",<AppID_FromCat>
"displayName": "MyTeamsMaintenance",

No matter which way of request (over the app individual external id or the given name) is chosen, the result is beneath other values a tenant individual app id which is needed for the next request: Installing. that app inside the just created team.

Install that app in the Team:

https://graph.microsoft.com/v1.0/teams/<TeamID>/installedApps

{
"teamsApp@odata.bind": "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/<AppID_FromCat>",
"consentedPermissionSet": {
"resourceSpecificPermissions": [
{
"permissionValue": "TeamSettings.ReadWrite.Group",
"permissionType": "application"
}
]
}
}

Test the result:

https://graph.microsoft.com/v1.0/teams/<YOUR_TEAM_ID>/permissionGrants
...
"value": [
{
"id": "eaVu2UipZG8_gI2-LnLbtrCQGT4zcHRZGj6f-zAy05A",
"clientAppId": "XXxxXXXXX-XxXX-xXXX-XXxx-XXXXXXXxxxXX",
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"clientId": "17d2372f-aad1-41eb-bdf3-6499dbaca34a",
"permissionType": "Application",
"permission": "TeamSettings.ReadWrite.Group"

}
]

And a decoded token will look like the following:

{
"typ": "JWT",
"nonce": "d6jHapSPtm8HI0C-ieWitrS3bkVH_Qk2Y10iazpfGjE",
"alg": "RS256",
"x5t": "T1St-dLTvyWRgxB_676u8krXS-I",
"kid": "T1St-dLTvyWRgxB_676u8krXS-I"
}.{
....,
"app_displayname": "TeamsRSCMaintenanceApp",
"appid": "XXxxXXXXX-XxXX-xXXX-XXxx-XXXXXXXxxxXX",
"appidacr": "1",
"idp": "https://sts.windows.net/<>TENANT_ID/",
"idtyp": "app",
"oid": "17d2372f-aad1-41eb-bdf3-6499dbaca34a",
"rh": "0.AU4AcdB3fgjtika8deglS6d6IQMAAAAAAAAAwAAAAAAAAABOAAA.",
"roles": [
"Group.Selected"
],
"sub": "17d2372f-aad1-41eb-bdf3-6499dbaca34a",
"tenant_region_scope": "EU",
....
}.[Signature]

Shortened for brevity but all the necessary properties can be detected. Clearly there is an app token and interesting role with “Group.selected”. That’s what it shall do further, to try to maintain specific Groups/Teams there is access to while to others is not.

Also the resource specific (masked) app registration XXxxXXXXX-XxXX-xXXX-XXxx-XXXXXXXxxxXX can be clearly followed here: Entered to the app manifest in combination with rsc permission requests only, installed to secifiic Teams and returned back (if installed).

Having that the Group.selected token can get the Group or Team and even manipulate this like the description. Not to mention all deeply folded Team’s fun e.g. settings on consented Teams only, of course.

Permissions

To establish this setup so far from a permission point of view and already shown in previous post (see link above) the following permissions are needed. Only the Grant Consent column is new here and responsible for the Teams Application installation which will give the resource specific consent as explained.

CreateInitial Members / OwnersTeamifyGrant ConsentMaintain (RSC)
Group.CreateUser.Read.All
Directory.Read.All
+Team.CreateTeamsAppInstallation.ReadWriteAndConsentForTeam.AllTeamSettings.ReadWrite.Group  
Team.Create User.Read.All
Directory.Read.All
N / ATeamsAppInstallation.ReadWriteAndConsentForTeam.All TeamSettings.ReadWrite.Group

That was the first part of permissions that can or need to be used for creation of the Teams.

The second part belongs to the maintenance part. This post will concentrate on the highest available permission “TeamSettings.ReadWrite.Group” that is able to manipulate a whole team, respectively its underlying group (but not to delete or adding/removing Team members ☝🏻). There are more granular permissions that can be used for other scenarios, too.

Maintenance

Finally, it’s time to test what was done. No matter how sensitive the request operation is, with the specific but high privileged permission “TeamSettings.ReadWrite.Group” any operation such as Read or Modify on a consented Team should work or not if not consented.

GET https://graph.microsoft.com/v1.0/teams/<TeamID>

Will simply return the Team or result in a 403 (Forbidden).

PATCH https://graph.microsoft.com/v1.0/teams/<TeamID>
{
"visibility": "public"
}

Will return a 201 with an empty result if positive. A negative response will return “Method not allowed which also makes sense at a second glance because already the Uri including the <TeamsID> is not reachable, not matter which Http method to be chosen.

Summary and outlook

At a first glance it looks like the Teams approach for resource specific consent (RSC) needs a bit more complexity to be established. But at a second look the help of an “in the middle app” to grant permissions makes sense. It acts like a predefined template for one or a set of permissions and can be granted by applying to specific resources. Not only Teams but others are not covered here.

Only an option to even allow to specifically delete a Team may be missed which would be possible by the more general scenario using Groups.ReadWrite.All

SharePoint currently seems to do it simpler but with the downside of a nearly unacceptable permission requirement compared to the sensitive scenario of specific access to sites (which by the way from a user perspective could be given by a site owner or site collection administrator. No tenant administrator needed what would more be an equivalent for Sites.FullControl.All)

Looking forward to a new announced feature for SharePoint around “Sites.Create.All”. Currently announced for March 2024. Let’s see how this approach solves Site provisioning with “least privilege” approach. So far here were the insights for Teams and how it can be done custom or why and how a commercial solution should be challenged for permission requirements.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.