[Azure DevOps] Get default project administrators team members programmatically
When working on Projects organization in Azure DevOps (AzDO), you want to establish governance and maybe get some reporting data.
Among all parameters available, you have some default ones such as the Project Administrator team, which is provided when you create a new project.
If you didn't have the "chance" to work with AzDO API, it's not as fluent as some other Microsoft's API like Graph or Azure ones (and I'm not talking about the .NET Client Libraries for which PRs are still pending to provide updates on methods).
And in that case, getting that default team's members requires some "jumps" from an API to another to get this info.
Let's have a look about this! In this article, I'll show you how to get what you came for, with REST API and .NET Client Libraries.
Important
In this article, I'll focus on the online version of Azure DevOps (aka Services) not Server or TFS.
Prerequisites
- An AzDO organization (you can create one with any Microsoft account here), with at least one existing Project
- The following AzDO permission level at least
- member of the default project team which is available in the Project's settings, on the "Teams" page
- For querying the AzDO services using .NET Client Libraries, you'll need to create a Personal Access Token (PAT) with at least the following scopes:
- Graph: Read
- Identity: Read
- Member Entitlement Management: Read
- Project and Team: Read
Querying the AzDO REST API
The first thing you have to know is that there's not one service / domain URL for the API. Below a short list of some URLs:
- https://dev.azure.com, if you want to query most of AzDO endpoints such as /_apis/git or /_apis/projects
- https://vsaex.dev.azure.com, if you want to query the Member Entitlement Management endpoints such as /_apis/groupentitlements
- https://vssps.dev.azure.com, if you want to query endpoints related to people / groups, like /_apis/identities or /_apis/graph/groups
- https://status.dev.azure.com, if you want to query the AzDO /_apis/status endpoint
I couldn't find anything on the web about the meaning / reason why it's cut on pieces like this. My guess is that, depending on what you are querying, the info is on a server or another.
So beware when you are jumping from an endpoint to another: you could face a 404 error page when the problem is just that the service / domain URL is not the right one.
To get default project team members, we will proceed in the following order:
- Getting Project's ID
- Getting Project's scope descriptor value
- Getting Project's groups (with the scope descriptor value obtained before)
- Getting default Project Administrators members
As all the queries will use the GET method, we'll use the browser (be sure to be authenticated in your AzDO Organization before).
Project ID
that's the simpliest one (here the {project_name}
would be "My Project"):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | {
"id": "40865cf2-d252-49dc-874f-294aa1d9ad9f", // <--- Take this id
"name": "{project_name}",
"description": "Test project",
"url": "https://dev.azure.com/{org}/_apis/projects/40865cf2-d252-49dc-874f-294aa1d9ad9f",
"state": "wellFormed",
"revision": 35,
"_links": {
"self": {
"href": "https://dev.azure.com/{org}/_apis/projects/40865cf2-d252-49dc-874f-294aa1d9ad9f"
},
"collection": {
"href": "https://dev.azure.com/{org}/_apis/projectCollections/11321932-248d-4b22-a9ee-efddaaf6930e"
},
"web": {
"href": "https://dev.azure.com/{org}/{project_name}"
}
},
"visibility": "private",
"defaultTeam": {
"id": "50fb2515-f8e8-4c4f-b786-54bfdd17ebf0",
"name": "Test project Team",
"url": "https://dev.azure.com/{org}/_apis/projects/408*65cf2-d252-49dc-874f-294aa1d9ad9f/teams/50fb2515-f8e8-4c4f-b786-54bfdd17ebf0"
},
"lastUpdateTime": "2019-11-05T13:25:37.617Z"
}
|
Project's scope descriptor value
Let's switch to another service (here the {project_id}
would be "40865cf2-d252-49dc-874f-294aa1d9ad9f"):
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | {
"value": "scp.ODRiNmMwNjMtMTRiNS00ZGUyLWE4YjgtNmFmOWZmZDNiNjdm", // <--- Take this value
"_links": {
"self": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Descriptors/{project_id}"
},
"storageKey": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/StorageKeys/scp.ODRiNmMwNjMtMTRiNS00ZGUyLWE4YjgtNmFmOWZmZDNiNjdm"
},
"subject": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Scopes/scp.ODRiNmMwNjMtMTRiNS00ZGUyLWE4YjgtNmFmOWZmZDNiNjdm"
}
}
}
|
Project's groups with the scope descriptor value
With the value obtained before (here the {project_scope_descriptor}
would be "scp.ODRiNmMwNjMtMTRiNS00ZGUyLWE4YjgtNmFmOWZmZDNiNjdm"):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32 | {
"count": 9,
"value": [
{
"subjectKind": "group",
"description": "Members of this group can perform all operations in the team project.",
"domain": "vstfs:///Classification/TeamProject/{project_id}",
"principalName": "[Test project]\\Project Administrators",
"mailAddress": null,
"origin": "vsts",
"originId": "95cf6f67-22a5-41ca-92a0-435f7a8eb948", // <--- Take this id
"displayName": "Project Administrators",
"_links": {
"self": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Groups/vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ"
},
"memberships": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Memberships/vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ"
},
"membershipState": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/MembershipStates/vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ"
},
"storageKey": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/StorageKeys/vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ"
}
},
"url": "https://vssps.dev.azure.com/{org}/_apis/Graph/Groups/vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ",
"descriptor": "vssgp.Uy0xLTktMTU1MTM3NDI0NS0xNjczNTc0MDIwLTMwMzgwNDQ3NDktMjgzMDY1ODI5Ny00MjkyMDY0ODk1LTAtMC0wLTAtMQ"
}
// ....
]
}
|
The cool thing about this group is that it's created by AzDO and has only read-only properties, so you can look for the "Project Administrators" display name without being worried about title changing.
Getting default Project Administrators members
Finally, with the Project Team ID through the originId
property, get the members (and switch to another service URL, where the {project_team_id}
would be "95cf6f67-22a5-41ca-92a0-435f7a8eb948"):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102 | {
"members": [
{
"id": "8b8948a8-a17a-60c5-8fb9-39985bbef603",
"user": {
"subjectKind": "user",
"metaType": "member",
"directoryAlias": "meganb",
"domain": "1fdd85e0-9a94-4593-8ab0-5ad1b834475f",
"principalName": "meganb@contoso.onmicrosoft.com",
"mailAddress": "meganb@contoso.onmicrosoft.com",
"origin": "aad",
"originId": "1bec3bfc-8fec-41a9-b408-f1b4fcf9aa52",
"displayName": "Megan Bowen",
"_links": {
"self": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Users/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"memberships": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Memberships/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"membershipState": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/MembershipStates/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"storageKey": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/StorageKeys/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"avatar": {
"href": "https://dev.azure.com/{org}/_apis/GraphProfile/MemberAvatars/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
}
},
"url": "https://vssps.dev.azure.com/{org}/_apis/Graph/Users/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz",
"descriptor": "aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"accessLevel": {
"licensingSource": "account",
"accountLicenseType": "stakeholder",
"msdnLicenseType": "none",
"licenseDisplayName": "Stakeholder",
"status": "active",
"statusMessage": "",
"assignmentSource": "unknown"
},
"lastAccessedDate": "2021-09-08T10:22:13.8704588Z",
"dateCreated": "2019-07-23T13:51:52.953Z",
"projectEntitlements": [],
"extensions": [],
"groupAssignments": []
}
],
"continuationToken": "aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz",
"totalCount": -1,
"items": [
{
"id": "8b8948a8-a17a-60c5-8fb9-39985bbef603",
"user": {
"subjectKind": "user",
"metaType": "member",
"directoryAlias": "meganb",
"domain": "1fdd85e0-9a94-4593-8ab0-5ad1b834475f",
"principalName": "meganb@contoso.onmicrosoft.com",
"mailAddress": "meganb@contoso.onmicrosoft.com",
"origin": "aad",
"originId": "1bec3bfc-8fec-41a9-b408-f1b4fcf9aa52",
"displayName": "Megan Bowen",
"_links": {
"self": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Users/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"memberships": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/Memberships/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"membershipState": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/MembershipStates/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"storageKey": {
"href": "https://vssps.dev.azure.com/{org}/_apis/Graph/StorageKeys/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"avatar": {
"href": "https://dev.azure.com/{org}/_apis/GraphProfile/MemberAvatars/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
}
},
"url": "https://vssps.dev.azure.com/{org}/_apis/Graph/Users/aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz",
"descriptor": "aad.OGI4OTQ4YTgtYTE3YS03MGM1LThmYjktMzk5ODViYmVmNjAz"
},
"accessLevel": {
"licensingSource": "account",
"accountLicenseType": "stakeholder",
"msdnLicenseType": "none",
"licenseDisplayName": "Stakeholder",
"status": "active",
"statusMessage": "",
"assignmentSource": "unknown"
},
"lastAccessedDate": "2021-09-08T10:22:13.8704588Z",
"dateCreated": "2019-07-23T13:51:52.953Z",
"projectEntitlements": [],
"extensions": [],
"groupAssignments": []
}
]
}
|
Why not using the /_api/teams endpoint
I realized that when your Organization has many Projects, you can waste some time to find the specific Project Administrators Team, as this endpoint doesn't provide query filters and works with paging. With this approach, you're sure to get to your Project's specific team.
Using the .NET Client Libraries
You'll need to install the following Nuget packages:
Below the complete console app code snippet (including authentication with a Personal Access Token / PAT).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49 | using Microsoft.TeamFoundation.Core.WebApi;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.Graph.Client;
using Microsoft.VisualStudio.Services.MemberEntitlementManagement.WebApi;
using Microsoft.VisualStudio.Services.WebApi;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ConsoleAppAzDO
{
public class Program
{
public static void Main(string[] args)
{
string orgnizationUrl = "contoso";
string personalAccessToken = "[YOUR_PAT]";
string projectName = "My Project";
// Authenticate to AzDO with a Personal Access Token
Uri url = new Uri("https://dev.azure.com/" + orgnizationUrl);
VssCredentials credentials = new VssBasicCredential("pat", personalAccessToken);
VssConnection connection = new VssConnection(url, credentials);
// Get client contexts (in order to run queries)
ProjectHttpClient projectCtx = connection.GetClient<ProjectHttpClient>();
GraphHttpClient graphCtx = connection.GetClient<GraphHttpClient>();
MemberEntitlementManagementHttpClient memberCtx = connection.GetClient<MemberEntitlementManagementHttpClient>();
// Get project info
TeamProject myProject = projectCtx.GetProject(projectName).Result;
// Get project descriptor
GraphDescriptorResult myProjectDescriptor = graphCtx.GetDescriptorAsync(myProject.Id).Result;
// Get all project's groups
PagedGraphGroups myProjectGroups = graphCtx.ListGroupsAsync(myProjectDescriptor.Value.ToString()).Result;
// Get default project administrators group
GraphGroup myProjectAdmins = myProjectGroups.GraphGroups.Where(gp => gp.PrincipalName.Contains("Project Administrators") && gp.DisplayName == "Project Administrators").FirstOrDefault();
// Get group members
List<GraphUser> myProjectAdminUsers = memberCtx.GetGroupMembersAsync(new Guid(myProjectAdmins.OriginId)).Result.Members.Select(mem => mem.User).ToList();
// Display admin members
myProjectAdminUsers.ForEach(user => Console.WriteLine(user.DisplayName));
}
}
}
|
Of course, you can go further into handling AzDO data. But you'll have to be patient and curious to get what you want, and eventually you'll have to query directly the REST API, because the .NET Client library won't provide what you expect 👀
Happy coding!
Useful Links