Security

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-api package

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:

  1. A break-glass account (cloud-only, no MFA bypass, monitored)
  2. Azure AD groups for designated org admins (never individual users)
  3. 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:

  1. Explicit Deny (highest priority)
  2. Explicit Allow
  3. Inherited Deny
  4. Inherited Allow
  5. 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

  1. Set the organization policy: Organization Settings > Policies > External guest access — set access level to Stakeholder
  2. Create a dedicated security group for guests with explicit, limited permissions
  3. 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

Powered by Contentful