How to manage custom security attributes through the Microsoft Graph C# SDK

Custom security attributes

Custom security attributes in Microsoft Entra ID are key-value pairs that you can define and later assign to Microsoft Entra user or service principals.

These attributes can be used to store information, categorize objects, or enforce fine-grained access control over specific Azure resources through Azure attribute-based access control (Azure ABAC).

Security attributes are grouped into attribute sets. Each attribute set can contain up to 500 attributes. You can create multiple attribute sets to organize your attributes. The security attribute can be either active or deprecated. Your tenant cannot contain more than 500 active security attributes.

The attribute sets and security attributes can be managed throught the Microsoft Graph API and of cource through any of the Microsoft Graph SDKs. This article shows how to manage security attributes through the Microsoft Graph C# SDK.

Mangage attribute sets with the Microsoft Graph C# SDK

The calling application must be granted the CustomSecAttributeDefinition.ReadWrite.All permission. Add Microsoft.Graph and Azure.Identity NuGet packages to your project.

Create attribute sets

When creating an attribute set, you need to specify its name. Optional parameters include the description and the maximum number of attributes that can be added to the set. The global maximum number of attributes in the set is 500.

public async Task<AttributeSet> CreateAttributeSetAsync(string name, string description, int? maxAttributes)
{
    var attributeSet = new AttributeSet
    {
        Id = name,
        Description = description,
        MaxAttributesPerSet = maxAttributes
    };
    try
    {
        return await graphServiceClient.Directory.AttributeSets.PostAsync(attributeSet);
    }
    catch (ODataError ex)
    {
        return null;
    }
}

Update attribute sets

Only the description and the maximum number of attributes can be updated. The name of the attribute set cannot be changed.

public async Task<AttributeSet> UpdateAttributeSetAsync(string name, string description, int? maxAttributes)
{
    var attributeSet = new AttributeSet
    {
        Description = description,
        MaxAttributesPerSet = maxAttributes
    };
    try
    {
        return await graphServiceClient.Directory.AttributeSets[name].PatchAsync(attributeSet);
    }
    catch (ODataError ex)
    {
        return null;
    }
}

List attribute sets

The Graph API doesn't return all attribute sets by one call, but provides the odata link to the next set of the results. To list all attribute sets, use the PageIterator to page through the all result sets.

public async Task<List<AttributeSet>> GetAttributeSetsAsync()
{
    var attributeSets = new List<AttributeSet>();
    var response = await graphServiceClient.Directory.AttributeSets.GetAsync();
    var pageIterator = PageIterator<AttributeSet, AttributeSetCollectionResponse>.CreatePageIterator(graphServiceClient, response, (attributeSet) =>
    {
        attributeSets.Add(attributeSet);
        return true;
    });

    await pageIterator.IterateAsync();
    return attributeSets;
}

Create security attributes

When creating a definition of a security attribute, you need to specify attribute set name, security attribute name, description, type, status, whether the attribute allow multiple values, whether the attribute is searchable and whether the security attribute can contain only predefined values or combination of predefined and custom values.

public async Task<CustomSecurityAttributeDefinition> AddDefinition(string attributeSetName, string secAttributeName, string description, string type, string status,
            bool isCollection, bool isSearchable, bool useOnlyPredefinedValues, List<string> predefinedValues)
{
    var body = new CustomSecurityAttributeDefinition
    {
        AttributeSet = attributeSetName,
        Name = secAttributeName,
        Description = description,
        IsCollection = isCollection,
        IsSearchable = isSearchable,
        UsePreDefinedValuesOnly = useOnlyPredefinedValues,
        Type = type,
        Status = status
    };

    if (predefinedValues != null)
    {
        body.AllowedValues = new List<AllowedValue>();
        foreach (var predefinedValue in predefinedValues)
        {
            body.AllowedValues.Add(new AllowedValue
            {
                Id = predefinedValue,
                IsActive = true
                });
            }
        }

        try
        {
            return await graphServiceClient.Directory.CustomSecurityAttributeDefinitions.PostAsync(body);
        }
        catch (ODataError ex)
        {
            return null;
        }
    }
}

Update security attributes

Only the description, status, whether the attribute allow multiple values, and predefined values can be updated.

public async Task<CustomSecurityAttributeDefinition> UpdateDefinition(string secAttributeId, string description, string status, bool? useOnlyPredefinedValues, List<string> predefinedValues)
{
    var body = new CustomSecurityAttributeDefinition();

    if (!string.IsNullOrEmpty(description))
    {
        body.Description = description;
    }

    if (!string.IsNullOrEmpty(status))
    {
        body.Status = status;
    }

    if (useOnlyPredefinedValues.HasValue)
    {
        body.UsePreDefinedValuesOnly = useOnlyPredefinedValues.Value;
    }

    if (predefinedValues != null)
    {
        body.AllowedValues = new List<AllowedValue>();
        foreach (var predefinedValue in predefinedValues)
        {
            body.AllowedValues.Add(new AllowedValue
            {
                Id = predefinedValue,
                IsActive = true
            });
        }
    }

    try
    {
        return await graphServiceClient.Directory.CustomSecurityAttributeDefinitions[secAttributeId].PatchAsync(body);
    }
    catch (ODataError ex)
    {
        return null;
    }
}

List security attributes

The Graph API doesn't return all security attributes by one call, but provides the odata link to the next set of the results. To list all security attributes, use the PageIterator to page through the all result sets.

You can filter the security attributes by attribute set name, security attribute name, status, and type.

public async Task<List<CustomSecurityAttributeDefinition>> GetDefinitions(string attributeSetName = null, string secAttributeName = null, string type = null, string status = null, bool includeAllowedValues = false)
{
    var filters = new List<string>();
    if (!string.IsNullOrEmpty(attributeSetName))
    {
        filters.Add($"attributeSet eq '{attributeSetName}'");
    }

    if (!string.IsNullOrEmpty(secAttributeName))
    {
        filters.Add($"name eq '{secAttributeName}'");
    }

    if (!string.IsNullOrEmpty(type))
    {
        filters.Add($"type eq '{type}'");
    }

    if (!string.IsNullOrEmpty(status))
    {
        filters.Add($"status eq '{status}'");
    }

    var response = await graphServiceClient.Directory.CustomSecurityAttributeDefinitions.GetAsync(rc =>
    {
        if (filters.Count > 0)
        {
            rc.QueryParameters.Filter = string.Join(" and ", filters);
        }

        if (includeAllowedValues)
        {
            rc.QueryParameters.Expand = ["allowedValues"];
        }
    });

    var definitions = new List<CustomSecurityAttributeDefinition>();
    var pageIterator = PageIterator<CustomSecurityAttributeDefinition, CustomSecurityAttributeDefinitionCollectionResponse>.CreatePageIterator(graphServiceClient, response, (definition) =>
    {
        definitions.Add(definition);
        return true;
    });

    await pageIterator.IterateAsync();

    return definitions;
}

Examples

Let's try some examples:

// create attribute set
var attributeSet = "EmployeeCompetencies";
await CreateAttributeSetAsync(attributeSet, "Competencies to access resource", 10);

// update attribute set
await UpdateAttributeSetAsync(attributeSet, "Employees competencies to access resources", 30);

// add definitions

// define String security attribute with predefined values
await AddDefinition(attributeSet, "English", "Level of English", "String", "Available", false, true, true, ["A1", "A2", "B1", "B2", "C1", "C2"]);

// define Integer security attribute without predefined values
await AddDefinition(attributeSet, "Microsoft365", "number of years of hands-on experience with Microsoft 365", "Integer", "Available", false, true, false, null);

// define Boolean security attribute
await AddDefinition(attributeSet, "SecurityCertification", "Whether the employee is authorized to work with sensitive data", "Boolean", "Available", false, true, false, null);

// define security attribute as collection of string values with predefined values
await AddDefinition(attributeSet, "EntraRoles", "Entra ID roles that can be assigned to the user", "String", "Available", true, true, true, ["User Administrator", "Application Administrator", "SharePoint Administrator", "Global Reader"]);

// define security attribute as collection of integer values without predefined values
await AddDefinition(attributeSet, "Projects", "Projects per year", "Integer", "Available", true, true, false, null);

// get all security attributes defined in the tenant
var definitions = await GetDefinitions();

// get all security attributes defined in the attribute set
var definitions = await GetDefinitions(attributeSetName: attributeSet);

// get all string security attributes defined in the attribute set
var definitions = await GetDefinitions(attributeSetName: attributeSet, type: "String");

// get all deprecated security attributes
var definitions = await GetDefinitions(status: "Deprecated");

// get all deprecated integer security attributes
var definitions = await GetDefinitions(type: "Integer", status: "Deprecated");

Assign security attributes to users and service principals

Let's focus now on how to assign security attributes to users. The calling application must be granted the User.Read.All and CustomSecAttributeAssignment.ReadWrite.All permissions. The User.Read.All permission is required, because we will access a specific user.

The User resource has a property customSecurityAttributes which is an open complex type that holds the value of a custom security attribute that is assigned to a user. The definition of the security attribute is dynamic and it can be quite challenge to write and read values of security attributes.

The JSON representation of the customSecurityAttributes property looks like this:

{
  "attributeSetName1": {
   "securityAttributeName1": "value1",
   "securityAttributeName2": "value2"
  },
  "attributeSetName2": {
   "securityAttributeName3": "value3",
   "securityAttributeName4": "value4"
  }
}

To work with the customSecurityAttributes property in the Graph C# SDK, you need to use the UntypedNode class. The UntypedNode class is a base class for:

  • UntypedString : Represents a string value.
  • UntypedInteger : Represents an integer value.
  • UntypedBoolean : Represents a boolean value.
  • UntypedArray : Represents an array of untyped nodes. Items in the array can have different types.
  • UntypedObject : Represents an object of untyped nodes. Properties in the object can have different types.

Assign security attributes to a user

public async Task<User> AddSecurityAttribute(string userId, string attributeSetName, List<(string secAttributeName, object secAttributeValue)> secAttributes)
{
    var body = new User
    {
        CustomSecurityAttributes = new CustomSecurityAttributeValue
        {
            AdditionalData = new Dictionary<string, object>
            {
                {
                    $"{attributeSetName}" , CreateAttributeSetWithSecurityAttributes(secAttributes)
                }
            }
        }
    };
    return await UpdateUser(userId, body);
}

I'm creating a User object and initializing the CustomSecurityAttributes property. As mentioned before, custom security attributes have dynamic structure and because of this the CustomSecurityAttributeValue class doesn't have any property except the AdditionalData property which is a dictionary of key-value pairs generated by the SDK.

In our case, we will use the AdditionalData. The key is the name of the attribute set and the value is an object of the UntypedNode class.

private UntypedObject CreateAttributeSetWithSecurityAttributes(List<(string secAttributeName, object secAttributeValue)> secAttributes)
{
    var properties = new Dictionary<string, UntypedNode>
    {
        ["@odata.type"] = new UntypedString("#Microsoft.DirectoryServices.CustomSecurityAttributeValue")
    };

    foreach (var (secAttributeName, secAttributeValue) in secAttributes)
    {
        UntypedNode attributeValue = null;

        if (secAttributeValue is string stringValue)
        {
            attributeValue = new UntypedString(stringValue);
        }
        else if (secAttributeValue is int intValue)
        {
            // we need to specify odata.type for integer values, otherwise the endpoint will return an error
            properties[$"{secAttributeName}@odata.type"] = new UntypedString("#Int32");
            attributeValue = new UntypedInteger(intValue);
        }
        else if (secAttributeValue is bool boolValue)
        {
            attributeValue = new UntypedBoolean(boolValue);
        }
        else if (secAttributeValue is IEnumerable<string> stringCollection)
        {
            var values = new List<UntypedNode>();
            foreach (var item in stringCollection)
            {
                values.Add(new UntypedString(item));
            }
            attributeValue = new UntypedArray(values);
        }
        else if (secAttributeValue is IEnumerable<int> intCollection)
        {
            var values = new List<UntypedNode>();
            foreach (var item in intCollection)
            {
                values.Add(new UntypedInteger(item));
            }
            attributeValue = new UntypedArray(values);
        }
        properties[secAttributeName] = attributeValue;
    }

    return new UntypedObject(properties);
}

Finally, update the user with the new security attributes.

private async Task<User> UpdateUser(string userId, User requestBody)
{
    try
    {
        return await graphServiceClient.Users[userId].PatchAsync(requestBody);
    }
    catch (ODataError ex)
    {
        return null;
    }
}

Get security attributes assigned to a user

When reading security attributes assigned to a user, you need to explicitly tell the Graph API to return the customSecurityAttributes property.

Then use the AdditionalData property to access specific attribute set. Attribute set is represented by the UntypedObject class

The customSecurityAttributes property is a dictionary of key-value pairs. The key is the name of the attribute set and the value is an object of the UntypedObject class.

public async Task<Dictionary<string, object>> GetSecurityAttributes(string userId, string attributeSetName)
{
    var secAttributes = new Dictionary<string, object>();
    try
    {
        var user = await graphServiceClient.Users[userId].GetAsync(rc =>
        {
            rc.QueryParameters.Select = ["customSecurityAttributes"];
        });

        var attributeSet = user.CustomSecurityAttributes.AdditionalData[attributeSetName];
        if (attributeSet is UntypedObject untypedObject)
        {
            var securityAttributes = untypedObject.GetValue();
            foreach (var securityAttribute in securityAttributes)
            {
                if (securityAttribute.Key.ToLowerInvariant().Contains("odata.type"))
                {
                    continue;
                }

                if (securityAttribute.Value is UntypedString untypedString)
                {
                    secAttributes.Add(securityAttribute.Key, untypedString.GetValue());
                }
                else if (securityAttribute.Value is UntypedInteger untypedInteger)
                {
                    secAttributes.Add(securityAttribute.Key, untypedInteger.GetValue());
                }
                else if (securityAttribute.Value is UntypedBoolean untypedBoolean)
                {
                    secAttributes.Add(securityAttribute.Key, untypedBoolean.GetValue());
                }
                else if (securityAttribute.Value is UntypedArray untypedArray)
                {
                    var values = new List<object>();
                    foreach (var item in untypedArray.GetValue())
                    {
                        if (item is UntypedString untypedStringItem)
                        {
                            values.Add(untypedStringItem.GetValue());
                        }
                        else if (item is UntypedInteger untypedIntegerItem)
                        {
                            values.Add(untypedIntegerItem.GetValue());
                        }
                    }
                    secAttributes.Add(securityAttribute.Key, values);
                }
            }
        }

        return secAttributes;
    }
    catch (ODataError ex)
    {
        return null;
    }
    catch (Exception ex)
    {
        return null;
    }
}

Examples

var userId = "ba497afe-2258-40d8-9490-4a4f34e4dfee";
await AddSecurityAttribute(userId,
        attributeSet,
        [
            ("English", "B2"),
            ("Microsoft365", 8),
            ("SecurityCertification", true),
            ("EntraRoles", new List<string> { "Application Administrator", "User Administrator" }),
            ("Projects", new List<int> { 2, 5, 4, 8 })
        ]);

var data = await usersService.GetSecurityAttributes(userId, attributeSet);

You can find the full code in the GitHub repository. It shows how to assign security attributes to a service principal and how to read them.

Summary

The Microsoft Graph C# SDK allows you to easily manage custom security attributes in Microsoft Entra ID. You can create and update attribute sets, create and update security attributes, assign security attributes to users and service principals, and read security attributes assigned to users and service principals.

0
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x