Identity and Access Management in Azure DevOps
Implement comprehensive identity and access management for Azure DevOps with Azure AD integration, least privilege, and automated permission auditing
Identity and Access Management in Azure DevOps
Identity and access management (IAM) is the backbone of securing your Azure DevOps organization, and getting it wrong means either locking out your teams or leaving the door wide open for unauthorized changes. This article covers the full spectrum of IAM in Azure DevOps — from Azure AD integration and conditional access policies to building automated permission auditing tools that catch over-privileged accounts before they become a breach vector. If you manage an Azure DevOps organization with more than a handful of contributors, this is the guide you need.
Prerequisites
- An Azure DevOps organization connected to Azure Active Directory (Entra ID)
- Global Administrator or Organization Owner permissions
- Node.js v16+ installed
- An Azure AD tenant with at least P1 licensing (for conditional access)
- A Personal Access Token (PAT) with full scope for auditing, or a service principal with appropriate Graph API permissions
- Familiarity with Azure DevOps REST APIs and the
azure-devops-node-apipackage
Azure AD Integration with Azure DevOps
The foundation of IAM in Azure DevOps is connecting your organization to Azure Active Directory. Once connected, Azure AD becomes the single identity provider — users authenticate through Azure AD, and you inherit all of its security features: MFA, conditional access, identity protection, and centralized lifecycle management.
Connecting Your Organization
Navigate to Organization Settings > Azure Active Directory and click Connect directory. This is a one-way street in practice. While you can technically disconnect, doing so wreaks havoc on permissions, group memberships, and audit trails.
Once connected, every user must be backed by an Azure AD identity. Microsoft accounts (MSAs) that do not exist in your tenant will need to be converted or removed.
var azdev = require("azure-devops-node-api");
var orgUrl = "https://dev.azure.com/your-organization";
var token = process.env.AZURE_DEVOPS_PAT;
var authHandler = azdev.getPersonalAccessTokenHandler(token);
var connection = new azdev.WebApi(orgUrl, authHandler);
function checkAadConnection() {
return connection.connect().then(function(connectionData) {
console.log("Connected as:", connectionData.authenticatedUser.providerDisplayName);
console.log("Organization ID:", connectionData.instanceId);
console.log("Authorized Scopes:", connectionData.authorizedUser.providerDisplayName);
return connectionData;
});
}
checkAadConnection().catch(function(err) {
console.error("Connection failed:", err.message);
});
Why Azure AD Over MSA-Backed Organizations
I have seen organizations try to run Azure DevOps with Microsoft accounts and it always ends the same way — someone leaves the company, nobody can disable their account centrally, and six months later their PAT is still active. Azure AD gives you:
- Centralized user provisioning and deprovisioning via SCIM
- Group-based license assignment
- Conditional access policies that enforce MFA, device compliance, and location restrictions
- Integration with Privileged Identity Management (PIM) for just-in-time elevation
User and Group Management
Azure AD Groups as the Source of Truth
Stop managing permissions at the individual user level. Every permission assignment in Azure DevOps should flow through Azure AD security groups. Here is the pattern I use:
Azure AD Group → Azure DevOps Team/Security Group
────────────────────────────────────────────────────────────────────
AzDO-Org-ProjectCollectionAdmins → Project Collection Administrators
AzDO-Proj-AppTeam-Contributors → [AppTeam] Contributors
AzDO-Proj-AppTeam-Readers → [AppTeam] Readers
AzDO-Proj-ReleaseMgrs → Release Managers (custom group)
AzDO-Proj-BuildAdmins → Build Administrators
Syncing Azure AD Groups
Azure DevOps automatically syncs Azure AD group memberships, but the sync is not instant. It runs periodically and can take up to 24 hours. You can force a sync through the REST API:
var https = require("https");
function forceGroupSync(orgName, groupDescriptor) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + orgName + "/_apis/graph/groups/" + groupDescriptor + "?api-version=7.1-preview.1",
method: "GET",
headers: {
"Authorization": "Basic " + Buffer.from(":" + process.env.AZURE_DEVOPS_PAT).toString("base64"),
"Content-Type": "application/json"
}
};
return new Promise(function(resolve, reject) {
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
resolve(JSON.parse(data));
} else {
reject(new Error("HTTP " + res.statusCode + ": " + data));
}
});
});
req.on("error", reject);
req.end();
});
}
Organization-Level Permissions
Organization-level permissions are the nuclear option. Anyone in the Project Collection Administrators group has unrestricted access to every project, repository, pipeline, and artifact in the organization. This group should have exactly three types of members:
- A break-glass account (cloud-only, no MFA bypass, monitored)
- Azure AD groups for designated org admins (never individual users)
- Service principals that need cross-project access (rare)
Auditing Organization-Level Memberships
var azdev = require("azure-devops-node-api");
var orgUrl = "https://dev.azure.com/your-organization";
var token = process.env.AZURE_DEVOPS_PAT;
function getOrgAdmins() {
var authHandler = azdev.getPersonalAccessTokenHandler(token);
var connection = new azdev.WebApi(orgUrl, authHandler);
return connection.connect().then(function() {
var graphApi = "https://vssps.dev.azure.com/your-organization/_apis/graph/groups?api-version=7.1-preview.1";
return connection.rest.get(graphApi);
}).then(function(response) {
var groups = response.result.value;
var pcaGroup = groups.find(function(g) {
return g.principalName === "[TEAM FOUNDATION]\\Project Collection Administrators";
});
if (!pcaGroup) {
throw new Error("PCA group not found");
}
console.log("Project Collection Administrators group descriptor:", pcaGroup.descriptor);
var membersUrl = "https://vssps.dev.azure.com/your-organization/_apis/graph/memberships/" +
pcaGroup.descriptor + "?direction=down&api-version=7.1-preview.1";
return connection.rest.get(membersUrl);
}).then(function(membersResponse) {
var members = membersResponse.result.value;
console.log("\nProject Collection Administrators - " + members.length + " members:");
members.forEach(function(member) {
console.log(" - " + member.memberDescriptor);
});
return members;
});
}
getOrgAdmins().catch(function(err) {
console.error("Failed to enumerate org admins:", err.message);
});
Project-Level Security Groups
Every Azure DevOps project ships with a default set of security groups:
| Group | Typical Use |
|---|---|
| Project Administrators | Full control over the project |
| Build Administrators | Manage build pipelines and definitions |
| Contributors | Day-to-day development work |
| Readers | Read-only access for stakeholders |
| Project Valid Users | Auto-populated, all project users |
| Release Administrators | Manage release pipelines |
Custom Security Groups
Default groups are too coarse for most organizations. Create custom groups that map to your actual roles:
var axios = require("axios");
var orgName = "your-organization";
var projectName = "your-project";
var pat = process.env.AZURE_DEVOPS_PAT;
var baseUrl = "https://vssps.dev.azure.com/" + orgName;
var authHeader = "Basic " + Buffer.from(":" + pat).toString("base64");
function createSecurityGroup(groupName, description, projectScopeDescriptor) {
var url = baseUrl + "/_apis/graph/groups?scopeDescriptor=" +
projectScopeDescriptor + "&api-version=7.1-preview.1";
return axios.post(url, {
displayName: groupName,
description: description
}, {
headers: {
"Authorization": authHeader,
"Content-Type": "application/json"
}
}).then(function(response) {
console.log("Created group:", response.data.displayName);
console.log("Descriptor:", response.data.descriptor);
return response.data;
});
}
// Create role-specific groups
var groups = [
{ name: "Senior Engineers", desc: "Can approve PRs and manage branch policies" },
{ name: "Release Managers", desc: "Can create and approve releases to production" },
{ name: "Security Reviewers", desc: "Read access to all repos, can manage security policies" },
{ name: "External Contractors", desc: "Limited contributor access, no admin capabilities" }
];
function createAllGroups(scopeDescriptor) {
var chain = Promise.resolve();
groups.forEach(function(group) {
chain = chain.then(function() {
return createSecurityGroup(group.name, group.desc, scopeDescriptor);
});
});
return chain;
}
Team Permissions Inheritance
Azure DevOps uses an inheritance model for permissions. Permissions flow from organization to project to team to individual resource. Understanding this chain is critical because an explicit Deny at any level overrides an Allow at a lower level.
The precedence order is:
- Explicit Deny (highest priority)
- Explicit Allow
- Inherited Deny
- Inherited Allow
- Not Set (defaults to Deny for most security-sensitive operations)
This means if you deny "Manage permissions" at the project level for a group, no member of that group can manage permissions on any resource within that project, even if they are explicitly allowed at the repository level. This trips people up constantly.
function evaluateEffectivePermissions(orgName, projectId, userDescriptor, securityNamespaceId) {
var url = "https://dev.azure.com/" + orgName + "/_apis/permissions/" +
securityNamespaceId + "/" + userDescriptor +
"?api-version=7.1-preview.2";
return axios.get(url, {
headers: { "Authorization": authHeader }
}).then(function(response) {
var permissions = response.data;
console.log("Effective permissions for user:");
console.log(JSON.stringify(permissions, null, 2));
return permissions;
});
}
Conditional Access Policies
Conditional access policies are enforced at the Azure AD level and apply to Azure DevOps when it is registered as an enterprise application. This is where you enforce the hard security boundaries.
Essential Policies for Azure DevOps
Policy 1: Require MFA for all Azure DevOps access
Configure in Azure AD > Security > Conditional Access:
- Users: All users
- Cloud apps: Azure DevOps
- Grant: Require multi-factor authentication
Policy 2: Block access from unmanaged devices for admin roles
- Users: AzDO-Org-ProjectCollectionAdmins group
- Cloud apps: Azure DevOps
- Conditions: Device state = Not compliant, Not Hybrid Azure AD joined
- Grant: Block access
Policy 3: Restrict access by location
- Users: All users
- Cloud apps: Azure DevOps
- Conditions: Locations = All locations except named (trusted) locations
- Grant: Require MFA + compliant device
Validating Conditional Access with the Graph API
var axios = require("axios");
function getConditionalAccessPolicies(accessToken) {
var url = "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies";
return axios.get(url, {
headers: {
"Authorization": "Bearer " + accessToken,
"Content-Type": "application/json"
}
}).then(function(response) {
var policies = response.data.value;
var azdevPolicies = policies.filter(function(policy) {
// Azure DevOps app ID: 499b84ac-1321-427f-aa17-267ca6975798
var targetApps = policy.conditions.applications.includeApplications || [];
return targetApps.indexOf("499b84ac-1321-427f-aa17-267ca6975798") !== -1 ||
targetApps.indexOf("All") !== -1;
});
console.log("Conditional Access Policies covering Azure DevOps:");
azdevPolicies.forEach(function(policy) {
console.log(" - " + policy.displayName + " (State: " + policy.state + ")");
console.log(" Grant controls:", JSON.stringify(policy.grantControls));
});
return azdevPolicies;
});
}
Guest Access Management
Guest users (B2B collaboration) in Azure AD can access Azure DevOps, but they require careful scoping. By default, guest users get the same access level as regular members once added to a project, which is almost never what you want.
Restricting Guest Access
- Set the organization policy: Organization Settings > Policies > External guest access — set access level to Stakeholder
- Create a dedicated security group for guests with explicit, limited permissions
- Never add guests to Contributors directly
function auditGuestAccess(orgName) {
var url = "https://vsaex.dev.azure.com/" + orgName +
"/_apis/userentitlements?api-version=7.1-preview.3&$filter=userType eq 'guest'";
return axios.get(url, {
headers: { "Authorization": authHeader }
}).then(function(response) {
var guests = response.data.members;
console.log("Guest users in organization: " + guests.length);
console.log("");
guests.forEach(function(guest) {
var user = guest.user;
var accessLevel = guest.accessLevel;
console.log("Guest: " + user.displayName + " (" + user.mailAddress + ")");
console.log(" Access Level: " + accessLevel.accountLicenseType);
console.log(" Last Access: " + (guest.lastAccessedDate || "Never"));
console.log(" Status: " + guest.accessLevel.status);
if (accessLevel.accountLicenseType !== "stakeholder") {
console.log(" ⚠ WARNING: Guest has higher than Stakeholder access!");
}
console.log("");
});
return guests;
});
}
Service Principal Authentication
Service principals are the correct way to authenticate automated processes — not shared PATs owned by individual humans. When someone leaves the company and their PAT is revoked, your entire CI/CD pipeline should not collapse.
Registering a Service Principal
var msal = require("@azure/msal-node");
var msalConfig = {
auth: {
clientId: process.env.AZURE_CLIENT_ID,
authority: "https://login.microsoftonline.com/" + process.env.AZURE_TENANT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET
}
};
var cca = new msal.ConfidentialClientApplication(msalConfig);
function getServicePrincipalToken() {
var tokenRequest = {
scopes: ["499b84ac-1321-427f-aa17-267ca6975798/.default"]
};
return cca.acquireTokenByClientCredential(tokenRequest).then(function(response) {
console.log("Token acquired for service principal");
console.log("Expires:", response.expiresOn);
return response.accessToken;
});
}
function callAzureDevOpsApi(accessToken, apiPath) {
var url = "https://dev.azure.com/your-organization" + apiPath;
return axios.get(url, {
headers: {
"Authorization": "Bearer " + accessToken,
"Content-Type": "application/json"
}
}).then(function(response) {
return response.data;
});
}
// Usage
getServicePrincipalToken().then(function(token) {
return callAzureDevOpsApi(token, "/_apis/projects?api-version=7.1");
}).then(function(projects) {
console.log("Projects accessible by service principal:");
projects.value.forEach(function(p) {
console.log(" - " + p.name);
});
}).catch(function(err) {
console.error("Service principal auth failed:", err.message);
});
Managed Identities for Pipelines
Managed identities eliminate the need for storing credentials in pipelines entirely. When running on Azure-hosted infrastructure, managed identities provide automatic credential rotation and no secret sprawl.
Configuring Workload Identity Federation
In your pipeline YAML, use workload identity federation instead of service connections backed by secrets:
# azure-pipelines.yml
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: AzureCLI@2
inputs:
azureSubscription: 'WorkloadIdentityConnection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
echo "Authenticated via workload identity federation"
az account show
az devops configure --defaults organization=https://dev.azure.com/your-org project=your-project
az devops security group list --output table
Auditing Service Connections
function auditServiceConnections(orgName, projectName) {
var url = "https://dev.azure.com/" + orgName + "/" + projectName +
"/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4";
return axios.get(url, {
headers: { "Authorization": authHeader }
}).then(function(response) {
var endpoints = response.data.value;
console.log("Service Connections in " + projectName + ":");
console.log("─".repeat(60));
endpoints.forEach(function(ep) {
console.log("Name: " + ep.name);
console.log(" Type: " + ep.type);
console.log(" Created By: " + ep.createdBy.displayName);
console.log(" Is Shared: " + ep.isShared);
console.log(" Authorization Scheme: " + ep.authorization.scheme);
if (ep.authorization.scheme === "ServicePrincipal") {
console.log(" Service Principal ID: " + ep.authorization.parameters.serviceprincipalid);
}
if (ep.data && ep.data.pipelinesAllowAllPipelines === "true") {
console.log(" ⚠ WARNING: All pipelines can use this connection!");
}
console.log("");
});
return endpoints;
});
}
Permission Auditing and Reporting
You cannot secure what you do not measure. Permission auditing needs to be automated and run on a schedule — not something you do once a quarter when the compliance team sends a reminder.
Security Namespaces
Azure DevOps organizes permissions into security namespaces. Each namespace controls access to a specific type of resource:
| Namespace ID | Resource |
|---|---|
2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 |
Git Repositories |
33344d9c-fc72-4d6f-aba5-fa317101a7e9 |
Build |
c788c23e-1b46-4162-8f5e-d7585343b5de |
Release Management |
5a27515b-ccd7-42c9-84f1-54c998f03866 |
Identity |
52d39943-cb85-4d7f-8fa8-c6baac873819 |
Project |
function listSecurityNamespaces(orgName) {
var url = "https://dev.azure.com/" + orgName +
"/_apis/securitynamespaces?api-version=7.1";
return axios.get(url, {
headers: { "Authorization": authHeader }
}).then(function(response) {
var namespaces = response.data.value;
console.log("Security Namespaces (" + namespaces.length + " total):");
namespaces.forEach(function(ns) {
console.log(" " + ns.namespaceId + " - " + ns.name);
if (ns.actions) {
ns.actions.forEach(function(action) {
console.log(" Action: " + action.name + " (bit: " + action.bit + ")");
});
}
});
return namespaces;
});
}
Least Privilege Implementation
Least privilege is not a one-time configuration. It is a continuous process of reducing permissions to the minimum required for each role. Here is my framework:
The Permission Matrix
Define a permission matrix that maps job functions to Azure DevOps permissions:
Role | Repos | Pipelines | Boards | Artifacts | Admin
──────────────────|────────────|────────────|─────────|───────────|──────
Junior Dev | Read/Write | Read | Read/Write | Read | None
Senior Dev | Read/Write | Read/Edit | Read/Write | Read/Write | None
Tech Lead | Read/Write | Full | Full | Full | Project
Release Manager | Read | Approve | Read | Read/Write | Release
Security Reviewer | Read | Read | None | Read | Audit
External Contractor| Read/Write*| None | Read/Write | None | None
* = specific repos only, not all repos in the project
Implementing Scoped Repository Access
function setRepositoryPermissions(orgName, projectId, repoId, groupDescriptor, permissions) {
// Git Repositories namespace: 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87
var namespaceId = "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87";
// Security token format for repos: repoV2/{projectId}/{repoId}
var securityToken = "repoV2/" + projectId + "/" + repoId;
var url = "https://dev.azure.com/" + orgName + "/_apis/accesscontrolentries/" +
namespaceId + "?api-version=7.1";
var body = {
token: securityToken,
merge: true,
accessControlEntries: [{
descriptor: groupDescriptor,
allow: permissions.allow,
deny: permissions.deny,
extendedInfo: {
effectiveAllow: permissions.allow,
effectiveDeny: permissions.deny
}
}]
};
return axios.post(url, body, {
headers: {
"Authorization": authHeader,
"Content-Type": "application/json"
}
}).then(function(response) {
console.log("Permissions updated for", securityToken);
return response.data;
});
}
// Git permission bits
var GIT_PERMISSIONS = {
ADMINISTER: 1,
GENERIC_READ: 2,
GENERIC_CONTRIBUTE: 4,
FORCE_PUSH: 8,
CREATE_BRANCH: 16,
CREATE_TAG: 32,
MANAGE_NOTE: 64,
POLICY_EXEMPT: 128,
CREATE_REPOSITORY: 256,
DELETE_REPOSITORY: 512,
RENAME_REPOSITORY: 1024,
EDIT_POLICIES: 2048,
REMOVE_OTHERS_LOCKS: 4096,
MANAGE_PERMISSIONS: 8192,
PULL_REQUEST_CONTRIBUTE: 16384,
PULL_REQUEST_BYPASS_POLICY: 32768
};
// Contractor: can read, contribute, create branches - cannot force push, administer, or bypass policies
var contractorPermissions = {
allow: GIT_PERMISSIONS.GENERIC_READ |
GIT_PERMISSIONS.GENERIC_CONTRIBUTE |
GIT_PERMISSIONS.CREATE_BRANCH |
GIT_PERMISSIONS.CREATE_TAG |
GIT_PERMISSIONS.PULL_REQUEST_CONTRIBUTE,
deny: GIT_PERMISSIONS.ADMINISTER |
GIT_PERMISSIONS.FORCE_PUSH |
GIT_PERMISSIONS.POLICY_EXEMPT |
GIT_PERMISSIONS.DELETE_REPOSITORY |
GIT_PERMISSIONS.MANAGE_PERMISSIONS |
GIT_PERMISSIONS.PULL_REQUEST_BYPASS_POLICY
};
Role-Based Access Control Patterns
Pattern 1: Environment-Based Elevation
Developers can deploy to dev and staging, but production deployments require a release manager approval through an Azure DevOps environment check:
# Production environment with approval gate
stages:
- stage: DeployProduction
jobs:
- deployment: ProductionDeploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to production"
Configure the production environment to require approval from the Release Managers group, and restrict pipeline permissions so only designated pipelines can target that environment.
Pattern 2: Branch-Level Security
Protect critical branches with policies that enforce specific group membership for approvals:
function createBranchPolicy(orgName, projectId, repositoryId) {
var url = "https://dev.azure.com/" + orgName + "/" + projectId +
"/_apis/policy/configurations?api-version=7.1";
var policy = {
isEnabled: true,
isBlocking: true,
type: {
id: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab" // Minimum reviewer count
},
settings: {
minimumApproverCount: 2,
creatorVoteCounts: false,
allowDownvotes: false,
resetOnSourcePush: true,
scope: [{
repositoryId: repositoryId,
refName: "refs/heads/main",
matchKind: "exact"
}]
}
};
return axios.post(url, policy, {
headers: {
"Authorization": authHeader,
"Content-Type": "application/json"
}
}).then(function(response) {
console.log("Branch policy created:", response.data.id);
return response.data;
});
}
Complete Working Example: Permission Audit Tool
This Node.js application audits Azure DevOps permissions across your organization, identifies over-privileged users, and generates actionable remediation recommendations.
// permission-auditor.js
var axios = require("axios");
var config = {
orgName: process.env.AZDO_ORG || "your-organization",
pat: process.env.AZURE_DEVOPS_PAT,
maxAdminsPerProject: 5,
maxOrgAdmins: 3,
inactiveDaysThreshold: 90,
reportFile: "permission-audit-report.json"
};
var authHeader = "Basic " + Buffer.from(":" + config.pat).toString("base64");
var headers = {
"Authorization": authHeader,
"Content-Type": "application/json"
};
var findings = [];
var stats = {
totalUsers: 0,
totalGroups: 0,
overPrivilegedUsers: 0,
inactiveAdmins: 0,
guestsWithElevatedAccess: 0,
sharedServiceConnections: 0
};
function addFinding(severity, category, title, description, remediation) {
findings.push({
severity: severity,
category: category,
title: title,
description: description,
remediation: remediation,
timestamp: new Date().toISOString()
});
}
function apiGet(baseUrl, path) {
var url = baseUrl + path;
return axios.get(url, { headers: headers }).then(function(r) { return r.data; });
}
// Audit 1: Organization administrators
function auditOrgAdmins() {
console.log("[1/6] Auditing organization administrators...");
return apiGet("https://vssps.dev.azure.com/" + config.orgName,
"/_apis/graph/groups?api-version=7.1-preview.1"
).then(function(data) {
var pcaGroup = data.value.find(function(g) {
return g.principalName.indexOf("Project Collection Administrators") !== -1;
});
if (!pcaGroup) {
addFinding("WARNING", "ORG_ADMINS", "Cannot locate PCA group",
"Unable to find Project Collection Administrators group", "Verify organization structure");
return;
}
return apiGet("https://vssps.dev.azure.com/" + config.orgName,
"/_apis/graph/memberships/" + pcaGroup.descriptor + "?direction=down&api-version=7.1-preview.1"
).then(function(membersData) {
var memberCount = membersData.value.length;
console.log(" Found " + memberCount + " org admin members");
if (memberCount > config.maxOrgAdmins) {
addFinding("HIGH", "ORG_ADMINS",
"Excessive organization administrators",
"Found " + memberCount + " members in Project Collection Administrators (threshold: " + config.maxOrgAdmins + ")",
"Remove unnecessary members. Use Azure AD PIM for just-in-time elevation instead of permanent membership."
);
stats.overPrivilegedUsers += (memberCount - config.maxOrgAdmins);
}
return membersData.value;
});
});
}
// Audit 2: Project-level admin sprawl
function auditProjectAdmins() {
console.log("[2/6] Auditing project administrators...");
return apiGet("https://dev.azure.com/" + config.orgName,
"/_apis/projects?api-version=7.1"
).then(function(projects) {
var chain = Promise.resolve();
projects.value.forEach(function(project) {
chain = chain.then(function() {
return apiGet("https://dev.azure.com/" + config.orgName + "/" + project.id,
"/_apis/security/groups?api-version=7.1-preview.1"
).then(function(groupsData) {
// Not all APIs return the same shape - handle gracefully
var groups = groupsData.value || [];
var adminGroup = groups.find(function(g) {
return g.displayName === "Project Administrators";
});
if (adminGroup && adminGroup.memberCount > config.maxAdminsPerProject) {
addFinding("MEDIUM", "PROJECT_ADMINS",
"Too many admins in " + project.name,
project.name + " has " + adminGroup.memberCount + " project administrators (threshold: " + config.maxAdminsPerProject + ")",
"Review membership. Move day-to-day users to Contributors. Use custom groups for specific elevated permissions."
);
}
}).catch(function(err) {
console.log(" Skipping " + project.name + ": " + err.message);
});
});
});
return chain;
});
}
// Audit 3: Guest user access levels
function auditGuestAccess() {
console.log("[3/6] Auditing guest user access...");
return apiGet("https://vsaex.dev.azure.com/" + config.orgName,
"/_apis/userentitlements?api-version=7.1-preview.3&$filter=userType eq 'guest'"
).then(function(data) {
var guests = data.members || [];
console.log(" Found " + guests.length + " guest users");
guests.forEach(function(guest) {
var accessLevel = guest.accessLevel.accountLicenseType;
if (accessLevel !== "stakeholder") {
addFinding("HIGH", "GUEST_ACCESS",
"Guest with elevated access: " + guest.user.displayName,
guest.user.mailAddress + " is a guest with " + accessLevel + " access level",
"Downgrade to Stakeholder access or convert to member if they need full access."
);
stats.guestsWithElevatedAccess++;
}
if (guest.lastAccessedDate) {
var lastAccess = new Date(guest.lastAccessedDate);
var daysSinceAccess = Math.floor((Date.now() - lastAccess.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceAccess > config.inactiveDaysThreshold) {
addFinding("MEDIUM", "INACTIVE_GUEST",
"Inactive guest: " + guest.user.displayName,
"Guest " + guest.user.mailAddress + " has not accessed Azure DevOps in " + daysSinceAccess + " days",
"Remove inactive guest accounts to reduce attack surface."
);
}
}
});
}).catch(function(err) {
console.log(" Guest audit skipped: " + err.message);
});
}
// Audit 4: Service connections
function auditServiceConnections() {
console.log("[4/6] Auditing service connections...");
return apiGet("https://dev.azure.com/" + config.orgName,
"/_apis/projects?api-version=7.1"
).then(function(projects) {
var chain = Promise.resolve();
projects.value.forEach(function(project) {
chain = chain.then(function() {
return apiGet("https://dev.azure.com/" + config.orgName + "/" + project.name,
"/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4"
).then(function(endpoints) {
(endpoints.value || []).forEach(function(ep) {
if (ep.data && ep.data.pipelinesAllowAllPipelines === "true") {
addFinding("HIGH", "SERVICE_CONNECTIONS",
"Unrestricted service connection: " + ep.name,
"Service connection '" + ep.name + "' in project '" + project.name + "' is accessible to all pipelines",
"Restrict service connection to specific pipelines. Go to Project Settings > Service connections > " + ep.name + " > Security and disable 'Allow all pipelines to use this connection'."
);
stats.sharedServiceConnections++;
}
});
}).catch(function() {
// Skip projects where we lack permission
});
});
});
return chain;
});
}
// Audit 5: User entitlements
function auditUserEntitlements() {
console.log("[5/6] Auditing user entitlements...");
return apiGet("https://vsaex.dev.azure.com/" + config.orgName,
"/_apis/userentitlements?api-version=7.1-preview.3&$top=1000"
).then(function(data) {
var users = data.members || [];
stats.totalUsers = users.length;
console.log(" Found " + users.length + " users");
users.forEach(function(user) {
if (user.lastAccessedDate) {
var lastAccess = new Date(user.lastAccessedDate);
var daysSinceAccess = Math.floor((Date.now() - lastAccess.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceAccess > config.inactiveDaysThreshold &&
user.accessLevel.accountLicenseType !== "stakeholder") {
addFinding("LOW", "INACTIVE_USER",
"Inactive licensed user: " + user.user.displayName,
user.user.mailAddress + " has " + user.accessLevel.accountLicenseType +
" license but has not accessed Azure DevOps in " + daysSinceAccess + " days",
"Downgrade to Stakeholder or remove to reclaim license."
);
stats.inactiveAdmins++;
}
}
});
});
}
// Audit 6: PAT usage
function auditPatUsage() {
console.log("[6/6] Checking for PAT-related risks...");
// The PAT lifecycle API is limited, but we can check token metadata
return apiGet("https://vssps.dev.azure.com/" + config.orgName,
"/_apis/tokenadmin/personalaccesstokens?api-version=7.1-preview.1&$top=100"
).then(function(data) {
var tokens = data.value || [];
console.log(" Found " + tokens.length + " PATs to review");
tokens.forEach(function(token) {
// Check for full-scope PATs
if (token.scope === "app_token") {
addFinding("HIGH", "PAT_SECURITY",
"Full-scope PAT detected",
"User has a PAT with full scope access. Token ID: " + token.authorizationId,
"Replace with scoped PATs that only have required permissions. Use service principals for automation."
);
}
// Check for long-lived PATs
if (token.validTo) {
var expiry = new Date(token.validTo);
var daysUntilExpiry = Math.floor((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry > 365) {
addFinding("MEDIUM", "PAT_SECURITY",
"Long-lived PAT detected",
"PAT valid for " + daysUntilExpiry + " more days. Token ID: " + token.authorizationId,
"Set maximum PAT lifetime to 90 days via Organization Settings > Policies."
);
}
}
});
}).catch(function(err) {
console.log(" PAT audit requires admin permissions: " + err.message);
});
}
// Generate report
function generateReport() {
var highCount = findings.filter(function(f) { return f.severity === "HIGH"; }).length;
var mediumCount = findings.filter(function(f) { return f.severity === "MEDIUM"; }).length;
var lowCount = findings.filter(function(f) { return f.severity === "LOW"; }).length;
var report = {
metadata: {
organization: config.orgName,
generatedAt: new Date().toISOString(),
auditor: "permission-auditor v1.0"
},
summary: {
totalFindings: findings.length,
high: highCount,
medium: mediumCount,
low: lowCount,
stats: stats
},
findings: findings.sort(function(a, b) {
var severityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2, WARNING: 3 };
return (severityOrder[a.severity] || 99) - (severityOrder[b.severity] || 99);
})
};
var fs = require("fs");
fs.writeFileSync(config.reportFile, JSON.stringify(report, null, 2));
console.log("\n" + "═".repeat(60));
console.log("PERMISSION AUDIT REPORT");
console.log("═".repeat(60));
console.log("Organization: " + config.orgName);
console.log("Generated: " + report.metadata.generatedAt);
console.log("Total Users: " + stats.totalUsers);
console.log("");
console.log("FINDINGS SUMMARY");
console.log("─".repeat(40));
console.log(" HIGH: " + highCount);
console.log(" MEDIUM: " + mediumCount);
console.log(" LOW: " + lowCount);
console.log("");
console.log("KEY METRICS");
console.log("─".repeat(40));
console.log(" Over-privileged users: " + stats.overPrivilegedUsers);
console.log(" Inactive admins/licensed: " + stats.inactiveAdmins);
console.log(" Guests with elevated access: " + stats.guestsWithElevatedAccess);
console.log(" Unrestricted svc connections: " + stats.sharedServiceConnections);
console.log("");
console.log("Full report written to: " + config.reportFile);
if (highCount > 0) {
console.log("\n⚠ HIGH SEVERITY FINDINGS REQUIRE IMMEDIATE ATTENTION:");
findings.filter(function(f) { return f.severity === "HIGH"; }).forEach(function(f) {
console.log(" - " + f.title);
console.log(" Remediation: " + f.remediation);
console.log("");
});
}
return report;
}
// Main execution
function runAudit() {
console.log("Starting Azure DevOps Permission Audit");
console.log("Organization: " + config.orgName);
console.log("═".repeat(60));
console.log("");
return auditOrgAdmins()
.then(function() { return auditProjectAdmins(); })
.then(function() { return auditGuestAccess(); })
.then(function() { return auditServiceConnections(); })
.then(function() { return auditUserEntitlements(); })
.then(function() { return auditPatUsage(); })
.then(function() { return generateReport(); })
.catch(function(err) {
console.error("Audit failed:", err.message);
if (err.response) {
console.error("Status:", err.response.status);
console.error("Response:", JSON.stringify(err.response.data, null, 2));
}
process.exit(1);
});
}
runAudit();
Sample Output
Starting Azure DevOps Permission Audit
Organization: contoso-engineering
════════════════════════════════════════════════════════════════
[1/6] Auditing organization administrators...
Found 7 org admin members
[2/6] Auditing project administrators...
[3/6] Auditing guest user access...
Found 12 guest users
[4/6] Auditing service connections...
[5/6] Auditing user entitlements...
Found 284 users
[6/6] Checking for PAT-related risks...
Found 43 PATs to review
════════════════════════════════════════════════════════════════
PERMISSION AUDIT REPORT
════════════════════════════════════════════════════════════════
Organization: contoso-engineering
Generated: 2026-02-13T14:30:22.441Z
Total Users: 284
FINDINGS SUMMARY
────────────────────────────────────────
HIGH: 5
MEDIUM: 8
LOW: 14
KEY METRICS
────────────────────────────────────────
Over-privileged users: 4
Inactive admins/licensed: 14
Guests with elevated access: 3
Unrestricted svc connections: 2
Full report written to: permission-audit-report.json
Common Issues and Troubleshooting
Issue 1: Azure AD Group Sync Not Reflecting Changes
Error: TF400813: The user '[email protected]' is not authorized to access this resource.
The Azure AD group membership sync to Azure DevOps can lag by up to 24 hours. Users recently added to an Azure AD group may not have their permissions reflected immediately. Force a re-evaluation by having the user sign out completely, clear their browser cache, and sign back in. If the problem persists after 24 hours, check that the Azure AD group is added to the correct Azure DevOps security group and that the group type is "Security" (not "Microsoft 365").
Issue 2: Conditional Access Blocking PAT Authentication
Error: TF401019: The Git repository with name or identifier 'repo-name' does not exist or you
do not have permissions for the operation you are attempting.
VS30063: You are not authorized to access https://dev.azure.com/org-name
Conditional access policies that require device compliance or managed device can block PAT-based authentication from non-compliant devices. PATs bypass interactive MFA but are still subject to IP-based and device-based conditional access policies (when "Enforce Conditional Access Policy Validation for Third Party OAuth" is enabled in organization settings). Either whitelist your automation server IPs in the conditional access policy or switch to service principal authentication with managed identity.
Issue 3: Service Principal Cannot Access Azure DevOps APIs
Error: 401 Unauthorized
{
"message": "Access Denied: The service principal does not have permission to access the resource.",
"$id": "1",
"innerException": null,
"typeName": "Microsoft.TeamFoundation.Framework.Server.UnauthorizedRequestException"
}
Service principals must be explicitly added to Azure DevOps. After registering the app in Azure AD, you must add the service principal to the Azure DevOps organization via Organization Settings > Users > Add users, selecting "Service Principal" as the user type. Then add the service principal to the appropriate security groups. Simply having an Azure AD app registration is not sufficient — Azure DevOps requires explicit enrollment.
Issue 4: Permission Inheritance Causing Unexpected Denials
Error: TF401027: You need the Git 'PullRequestContribute' permission to perform this action.
A common trap: a user is in the Contributors group (which has PullRequestContribute allowed) and also in a custom "External Contractors" group (which has an explicit Deny on PullRequestContribute). The Deny wins because explicit Deny always takes precedence, regardless of which group granted the Allow. To diagnose, use the Security tab on the repository, select the user, and click Why? next to each permission to trace the inheritance chain. The fix is to restructure groups so that Deny rules are not applied to groups that overlap with groups granting Allow.
Issue 5: User Entitlement API Pagination Limits
Error: Response contains only 100 of 500 users. Pagination token not handled.
The User Entitlements API returns a maximum of 100 results per page by default. You must handle the continuationToken in the response headers to paginate through all users:
function getAllEntitlements(orgName) {
var allMembers = [];
var continuationToken = null;
function fetchPage() {
var url = "https://vsaex.dev.azure.com/" + orgName +
"/_apis/userentitlements?api-version=7.1-preview.3&$top=500";
if (continuationToken) {
url += "&continuationToken=" + continuationToken;
}
return axios.get(url, { headers: headers }).then(function(response) {
allMembers = allMembers.concat(response.data.members || []);
continuationToken = response.headers["x-ms-continuationtoken"];
if (continuationToken) {
return fetchPage();
}
return allMembers;
});
}
return fetchPage();
}
Best Practices
Use Azure AD groups exclusively for permission management. Never assign permissions to individual users in Azure DevOps. Every permission assignment should trace back to an Azure AD security group. This makes onboarding, offboarding, and auditing dramatically simpler and gives you a single pane of glass in Azure AD for access reviews.
Enforce maximum PAT lifetime at the organization level. Go to Organization Settings > Policies and set the maximum lifetime to 90 days or less. Full-scope PATs should be prohibited entirely — enforce scoped tokens through policy. Better yet, migrate automated processes to service principals with managed identity credentials.
Implement Privileged Identity Management (PIM) for admin roles. No one should be a permanent member of Project Collection Administrators. Use Azure AD PIM to require just-in-time activation with approval workflows, time-limited access (maximum 8 hours), and MFA re-verification for elevation. This reduces your standing admin exposure from 24/7 to only when actively needed.
Run automated permission audits weekly. Deploy the audit tool from this article as a scheduled pipeline that runs every Monday, generates a report, and posts findings to a Teams channel or creates work items for high-severity issues. Quarterly manual reviews are not sufficient — permission drift happens continuously.
Separate service connections per environment and restrict pipeline access. Never share a production service connection across all pipelines. Create environment-specific connections (dev, staging, production) and restrict each to only the pipelines that legitimately need it. Enable the "Restrict access to specific pipelines" setting on every service connection.
Apply branch security policies on all protected branches. At minimum, require two reviewers on main/release branches, disallow direct pushes, require linked work items, and enable the "Reset approval on source push" option. Add required reviewers from a "Senior Engineers" security group for critical paths.
Audit guest access monthly. Guest users should default to Stakeholder access level. Any guest with Basic or higher access needs documented justification. Remove guests who have not accessed the organization in 60 days. Use Azure AD access reviews to automate this process.
Tag all security groups with purpose and owner. Include the team, purpose, and responsible manager in the group description. When you audit permissions six months from now, you will not remember why "TempAccessGroupQ3" exists or who approved it. Documentation in the group description prevents orphaned groups from accumulating.
References
- Azure DevOps Security REST API — Official API reference for security namespaces, ACLs, and access control entries
- Azure DevOps Graph API — API for managing users, groups, and memberships
- User Entitlements API — Managing user access levels and licenses
- Connect Azure DevOps to Azure Active Directory — Official guide for Azure AD integration
- Azure AD Conditional Access for Azure DevOps — Configuring conditional access policies
- Security Namespaces Reference — Complete list of security namespaces and permission bits
- Service Principal Authentication — Using service principals and managed identities with Azure DevOps
- Workload Identity Federation for Pipelines — Secretless authentication for pipeline service connections