Security

Identity and Access Management in Azure DevOps

Comprehensive guide to identity and access management in Azure DevOps, covering Azure AD integration, group-based permissions, conditional access policies, service principals, managed identities, and RBAC patterns for enterprise teams.

Identity and Access Management in Azure DevOps

Overview

Identity and access management is the foundation of every security posture in Azure DevOps. Who can see what, who can change what, and who can deploy what — these questions determine whether your organization is secure or just hoping nobody does something they should not. I have seen teams with wide-open permissions discover it only after a junior developer accidentally deleted a production service connection. Getting IAM right from the start saves you from those incidents.

Prerequisites

  • Azure DevOps organization connected to Azure Active Directory (Azure AD)
  • Global Administrator or User Administrator role in Azure AD for directory configuration
  • Project Collection Administrator permissions in Azure DevOps
  • Node.js 16 or later for automation scripts
  • Personal Access Token with identity and security management scopes
  • Basic understanding of Azure AD concepts (tenants, groups, app registrations)

Azure AD Integration Fundamentals

Azure DevOps organizations backed by Azure AD inherit the full identity platform — single sign-on, multi-factor authentication, conditional access, and group-based licensing. If your organization is still using Microsoft accounts (MSAs) instead of Azure AD, fixing that is step one.

Connecting Azure DevOps to Azure AD

# Verify current directory connection via REST API
curl -s -u :$PAT \
  "https://dev.azure.com/{organization}/_apis/connectiondata" \
  | jq '.authenticatedUser'

Once connected, every user authenticates through Azure AD. This gives you:

  • Single Sign-On (SSO): Users sign in once and access Azure DevOps without separate credentials
  • MFA enforcement: Require multi-factor authentication for all DevOps access
  • Conditional Access: Block access from untrusted networks or non-compliant devices
  • Centralized lifecycle: Disable an Azure AD account and the user loses DevOps access immediately

User Lifecycle Management

var https = require("https");

// Query Azure DevOps users with their access levels
function listOrganizationUsers(organization, pat) {
  var options = {
    hostname: "vsaex.dev.azure.com",
    path: "/" + organization + "/_apis/userentitlements?top=500&api-version=7.1",
    method: "GET",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json"
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        var data = JSON.parse(body);
        resolve(data.members || []);
      });
    });
    req.on("error", reject);
    req.end();
  });
}

// Find users who haven't accessed the organization recently
function findInactiveUsers(members, daysThreshold) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - daysThreshold);

  return members.filter(function(member) {
    var lastAccess = member.lastAccessedDate
      ? new Date(member.lastAccessedDate)
      : null;

    if (!lastAccess || lastAccess.getTime() === 0) {
      return true; // Never accessed
    }
    return lastAccess < cutoff;
  }).map(function(member) {
    return {
      displayName: member.user.displayName,
      principalName: member.user.principalName,
      lastAccessed: member.lastAccessedDate,
      accessLevel: member.accessLevel.licenseDisplayName
    };
  });
}

// Run the inactive user report
listOrganizationUsers("my-org", process.env.ADO_PAT)
  .then(function(members) {
    var inactive = findInactiveUsers(members, 90);
    console.log("Inactive users (90+ days):");
    console.log("Total: " + inactive.length + " of " + members.length);
    inactive.forEach(function(user) {
      console.log("  " + user.displayName + " (" + user.accessLevel + ") - Last: " + (user.lastAccessed || "Never"));
    });
  });

Output:

Inactive users (90+ days):
Total: 12 of 87
  John Smith (Basic) - Last: 2025-08-14T00:00:00Z
  Legacy Service (Stakeholder) - Last: Never
  Former Contractor (Basic + Test Plans) - Last: 2025-06-22T00:00:00Z
  ...

Group-Based Permission Model

Individual permission assignments are the enemy of maintainability. When someone joins or leaves a team, you should change a group membership — not hunt through dozens of permission screens.

Designing a Group Hierarchy

Organization Level:
├── [Organization] DevOps Admins        → Organization-level settings
├── [Organization] Security Auditors    → Read-only audit access
│
Project Level:
├── [Project] Developers               → Code read/write, build read
├── [Project] Senior Developers        → + branch policy bypass, PR approval
├── [Project] Release Managers          → + pipeline approval, environment management
├── [Project] DevOps Engineers          → + service connection admin, agent pool admin
└── [Project] Stakeholders             → Read-only access to boards and wikis

Creating and Configuring Groups Programmatically

var https = require("https");

function createSecurityGroup(organization, projectId, groupName, description, pat) {
  var postData = JSON.stringify({
    displayName: groupName,
    description: description
  });

  var options = {
    hostname: "vssps.dev.azure.com",
    path: "/" + organization + "/_apis/graph/groups?scopeDescriptor=scp." + projectId + "&api-version=7.1-preview.1",
    method: "POST",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve(JSON.parse(body));
      });
    });
    req.on("error", reject);
    req.write(postData);
    req.end();
  });
}

// Add an Azure AD group as a member of a DevOps group
function addGroupMembership(organization, containerDescriptor, memberDescriptor, pat) {
  var options = {
    hostname: "vssps.dev.azure.com",
    path: "/" + organization + "/_apis/graph/memberships/" + memberDescriptor + "/" + containerDescriptor + "?api-version=7.1-preview.1",
    method: "PUT",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json"
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve(JSON.parse(body));
      });
    });
    req.on("error", reject);
    req.end();
  });
}

// Provision standard groups for a new project
function provisionProjectGroups(organization, projectId, pat) {
  var groups = [
    { name: "Developers", description: "Standard development team members" },
    { name: "Senior Developers", description: "Senior devs with elevated permissions" },
    { name: "Release Managers", description: "Approve and manage releases" },
    { name: "DevOps Engineers", description: "Infrastructure and pipeline administrators" },
    { name: "Security Auditors", description: "Read-only security and compliance review" }
  ];

  var promises = groups.map(function(group) {
    return createSecurityGroup(organization, projectId, group.name, group.description, pat)
      .then(function(result) {
        console.log("Created group: " + group.name + " (" + result.descriptor + ")");
        return result;
      });
  });

  return Promise.all(promises);
}

Setting Namespace Permissions

Azure DevOps uses security namespaces to control access to different resource types. Each namespace has a set of permission bits.

var https = require("https");

// Key security namespace IDs
var NAMESPACES = {
  Git: "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87",
  Build: "33344d9c-fc72-4d6f-aba5-fa317c3b6175",
  ReleaseManagement: "c788c23e-1b46-4162-8f5e-d7585343b5de",
  Identity: "5a27515b-ccd7-42c9-84f1-54c998f03866",
  ServiceEndpoints: "49b48001-ca20-4adc-8111-5b60c903a50c",
  Environment: "83d4c2e6-e57d-4d6e-892b-b87222b7ad20"
};

// Permission bit flags for Git repositories
var GIT_PERMISSIONS = {
  Administer: 1,
  GenericRead: 2,
  GenericContribute: 4,
  ForcePush: 8,
  CreateBranch: 16,
  CreateTag: 32,
  ManageNote: 64,
  PolicyExempt: 128,
  CreateRepository: 256,
  DeleteRepository: 512,
  RenameRepository: 1024,
  EditPolicies: 2048,
  RemoveOthersLocks: 4096,
  ManagePermissions: 8192,
  PullRequestContribute: 16384,
  PullRequestBypassPolicy: 32768
};

function setPermission(organization, namespaceId, token, descriptor, allow, deny, pat) {
  var postData = JSON.stringify({
    token: token,
    merge: true,
    accessControlEntries: [
      {
        descriptor: descriptor,
        allow: allow,
        deny: deny,
        extendedInfo: {}
      }
    ]
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + organization + "/_apis/accesscontrolentries/" + namespaceId + "?api-version=7.1",
    method: "POST",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve(JSON.parse(body));
      });
    });
    req.on("error", reject);
    req.write(postData);
    req.end();
  });
}

// Example: Set developer permissions on a repository
// Allow: read, contribute, create branch, create tag, PR contribute
// Deny: force push, delete repo, policy exempt
var allowBits = GIT_PERMISSIONS.GenericRead
  | GIT_PERMISSIONS.GenericContribute
  | GIT_PERMISSIONS.CreateBranch
  | GIT_PERMISSIONS.CreateTag
  | GIT_PERMISSIONS.PullRequestContribute;

var denyBits = GIT_PERMISSIONS.ForcePush
  | GIT_PERMISSIONS.DeleteRepository
  | GIT_PERMISSIONS.PolicyExempt;

setPermission(
  "my-org",
  NAMESPACES.Git,
  "reposV2/project-id/repo-id",
  "vssgp.developer-group-descriptor",
  allowBits,
  denyBits,
  process.env.ADO_PAT
).then(function(result) {
  console.log("Permissions set:", JSON.stringify(result, null, 2));
});

Conditional Access Policies

Conditional Access in Azure AD lets you enforce context-based rules for Azure DevOps access — require MFA, block risky sign-ins, or restrict access to managed devices.

Configuring Conditional Access for Azure DevOps

In the Azure Portal:

  1. Navigate to Azure Active Directory > Security > Conditional Access
  2. Create a new policy targeting the Azure DevOps cloud app (application ID: 499b84ac-1321-427f-aa17-267ca6975798)
  3. Configure conditions and controls

Common policy configurations:

Policy: "Require MFA for Azure DevOps"
  Assignments:
    Users: All users (exclude break-glass accounts)
    Cloud apps: Azure DevOps (499b84ac-1321-427f-aa17-267ca6975798)
  Conditions:
    Client apps: Browser, Mobile apps and desktop clients
  Access controls:
    Grant: Require multi-factor authentication

Policy: "Block Azure DevOps from untrusted locations"
  Assignments:
    Users: All users
    Cloud apps: Azure DevOps
  Conditions:
    Locations: Exclude trusted named locations (office IPs, VPN)
  Access controls:
    Grant: Block access

Policy: "Require compliant device for Azure DevOps"
  Assignments:
    Users: Release Managers, DevOps Engineers
    Cloud apps: Azure DevOps
  Conditions:
    Device platforms: All platforms
  Access controls:
    Grant: Require device to be marked as compliant

Validating Conditional Access with Automation

var https = require("https");

// Test Azure DevOps access from current context
function validateAccess(organization, pat) {
  var options = {
    hostname: "dev.azure.com",
    path: "/" + organization + "/_apis/projects?api-version=7.1",
    method: "GET",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64")
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        if (res.statusCode === 200) {
          resolve({ status: "allowed", code: res.statusCode });
        } else if (res.statusCode === 401 || res.statusCode === 403) {
          resolve({ status: "blocked", code: res.statusCode, reason: body });
        } else {
          resolve({ status: "error", code: res.statusCode });
        }
      });
    });
    req.on("error", reject);
    req.end();
  });
}

// Monitor sign-in logs for conditional access blocks
function checkSignInLogs(tenantId, clientId, clientSecret) {
  // First, get an access token using client credentials
  var tokenData = "grant_type=client_credentials"
    + "&client_id=" + clientId
    + "&client_secret=" + encodeURIComponent(clientSecret)
    + "&scope=https://graph.microsoft.com/.default";

  var tokenOptions = {
    hostname: "login.microsoftonline.com",
    path: "/" + tenantId + "/oauth2/v2.0/token",
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Content-Length": Buffer.byteLength(tokenData)
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(tokenOptions, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        var token = JSON.parse(body).access_token;

        // Query sign-in logs filtered by Azure DevOps app
        var logOptions = {
          hostname: "graph.microsoft.com",
          path: "/v1.0/auditLogs/signIns?$filter=appId eq '499b84ac-1321-427f-aa17-267ca6975798' and status/errorCode ne 0&$top=50",
          method: "GET",
          headers: {
            "Authorization": "Bearer " + token
          }
        };

        var logReq = https.request(logOptions, function(logRes) {
          var logBody = "";
          logRes.on("data", function(chunk) { logBody += chunk; });
          logRes.on("end", function() {
            var logs = JSON.parse(logBody);
            resolve(logs.value || []);
          });
        });
        logReq.on("error", reject);
        logReq.end();
      });
    });
    req.on("error", reject);
    req.write(tokenData);
    req.end();
  });
}

Service Principals and Managed Identities

Human identities are only half the IAM picture. Pipelines, scripts, and integrations need identities too — and they should never use personal credentials.

Service Principal Setup for Azure DevOps

# Create a service principal in Azure AD
az ad sp create-for-rbac \
  --name "azure-devops-pipeline-sp" \
  --role "Contributor" \
  --scopes "/subscriptions/{subscription-id}/resourceGroups/{rg-name}"

# Output:
# {
#   "appId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
#   "displayName": "azure-devops-pipeline-sp",
#   "password": "abc123~secret",
#   "tenant": "d4e5f6a7-b8c9-0123-defg-456789012345"
# }

Creating Service Connections with Service Principals

var https = require("https");

function createAzureServiceConnection(organization, projectId, config, pat) {
  var postData = JSON.stringify({
    name: config.name,
    type: "azurerm",
    url: "https://management.azure.com/",
    authorization: {
      scheme: "ServicePrincipal",
      parameters: {
        tenantid: config.tenantId,
        serviceprincipalid: config.appId,
        authenticationType: "spnKey",
        serviceprincipalkey: config.secret
      }
    },
    data: {
      subscriptionId: config.subscriptionId,
      subscriptionName: config.subscriptionName,
      environment: "AzureCloud",
      scopeLevel: "Subscription",
      creationMode: "Manual"
    },
    serviceEndpointProjectReferences: [
      {
        projectReference: { id: projectId },
        name: config.name,
        description: config.description
      }
    ]
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + organization + "/_apis/serviceendpoint/endpoints?api-version=7.1",
    method: "POST",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve(JSON.parse(body));
      });
    });
    req.on("error", reject);
    req.write(postData);
    req.end();
  });
}

// Create scoped service connections per environment
var environments = [
  {
    name: "Azure-Dev",
    description: "Development subscription access",
    subscriptionId: "sub-dev-id",
    subscriptionName: "Dev Subscription",
    tenantId: process.env.AZURE_TENANT_ID,
    appId: process.env.SP_DEV_APP_ID,
    secret: process.env.SP_DEV_SECRET
  },
  {
    name: "Azure-Staging",
    description: "Staging subscription access",
    subscriptionId: "sub-staging-id",
    subscriptionName: "Staging Subscription",
    tenantId: process.env.AZURE_TENANT_ID,
    appId: process.env.SP_STAGING_APP_ID,
    secret: process.env.SP_STAGING_SECRET
  },
  {
    name: "Azure-Production",
    description: "Production subscription - restricted access",
    subscriptionId: "sub-prod-id",
    subscriptionName: "Production Subscription",
    tenantId: process.env.AZURE_TENANT_ID,
    appId: process.env.SP_PROD_APP_ID,
    secret: process.env.SP_PROD_SECRET
  }
];

environments.forEach(function(env) {
  createAzureServiceConnection("my-org", "project-id", env, process.env.ADO_PAT)
    .then(function(result) {
      console.log("Created service connection: " + result.name + " (ID: " + result.id + ")");
    });
});

Using Managed Identities in Pipelines

Managed identities eliminate secrets entirely. When your pipeline agents run on Azure VMs or Azure Container Instances, they can authenticate using the VM's identity.

# azure-pipelines.yml - Using managed identity
pool:
  name: 'Self-Hosted-Linux'  # Agents running on Azure VMs with managed identity

steps:
  - task: AzureCLI@2
    displayName: 'Deploy with Managed Identity'
    inputs:
      azureSubscription: 'Azure-Production-MI'  # Service connection using managed identity
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        # No credentials needed - uses VM's managed identity
        az account show
        az webapp deployment source config-zip \
          --resource-group myapp-rg \
          --name myapp-prod \
          --src $(Build.ArtifactStagingDirectory)/app.zip

  - task: AzureKeyVault@2
    displayName: 'Fetch secrets via managed identity'
    inputs:
      azureSubscription: 'Azure-Production-MI'
      KeyVaultName: 'myapp-keyvault'
      SecretsFilter: 'db-connection-string,api-key'
      RunAsPreJob: true

RBAC Patterns for Enterprise Teams

The Principle of Least Privilege in Practice

var https = require("https");

// Audit current permissions for a group across all projects
function auditGroupPermissions(organization, groupDescriptor, pat) {
  var namespaces = [
    { id: "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87", name: "Git Repositories" },
    { id: "33344d9c-fc72-4d6f-aba5-fa317c3b6175", name: "Build" },
    { id: "c788c23e-1b46-4162-8f5e-d7585343b5de", name: "ReleaseManagement" },
    { id: "49b48001-ca20-4adc-8111-5b60c903a50c", name: "ServiceEndpoints" },
    { id: "83d4c2e6-e57d-4d6e-892b-b87222b7ad20", name: "Environment" }
  ];

  var promises = namespaces.map(function(ns) {
    return new Promise(function(resolve, reject) {
      var options = {
        hostname: "dev.azure.com",
        path: "/" + organization + "/_apis/accesscontrollists/" + ns.id
          + "?descriptors=" + groupDescriptor + "&api-version=7.1",
        method: "GET",
        headers: {
          "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64")
        }
      };

      var req = https.request(options, function(res) {
        var body = "";
        res.on("data", function(chunk) { body += chunk; });
        res.on("end", function() {
          var data = JSON.parse(body);
          resolve({
            namespace: ns.name,
            acls: data.value || []
          });
        });
      });
      req.on("error", reject);
      req.end();
    });
  });

  return Promise.all(promises);
}

// Generate a permission report
function generatePermissionReport(organization, groupDescriptor, pat) {
  return auditGroupPermissions(organization, groupDescriptor, pat)
    .then(function(results) {
      var report = {
        generatedAt: new Date().toISOString(),
        group: groupDescriptor,
        namespaces: []
      };

      results.forEach(function(result) {
        var nsReport = {
          name: result.namespace,
          entries: []
        };

        result.acls.forEach(function(acl) {
          Object.keys(acl.acesDictionary || {}).forEach(function(key) {
            var ace = acl.acesDictionary[key];
            nsReport.entries.push({
              token: acl.token,
              allow: ace.allow,
              deny: ace.deny,
              inherited: ace.inherited || false
            });
          });
        });

        report.namespaces.push(nsReport);
      });

      return report;
    });
}

generatePermissionReport("my-org", "vssgp.my-group-descriptor", process.env.ADO_PAT)
  .then(function(report) {
    console.log(JSON.stringify(report, null, 2));
  });

Environment-Based Access Control

# azure-pipelines.yml - Environment approvals and checks
stages:
  - stage: DeployDev
    jobs:
      - deployment: DeployWeb
        environment: 'development'  # No approvals needed
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to dev"

  - stage: DeployStaging
    dependsOn: DeployDev
    jobs:
      - deployment: DeployWeb
        environment: 'staging'  # Requires team lead approval
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to staging"

  - stage: DeployProduction
    dependsOn: DeployStaging
    jobs:
      - deployment: DeployWeb
        environment: 'production'  # Requires 2 approvals + business hours check
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to production"

Configure environments with checks:

// Set up environment approvals programmatically
function configureEnvironmentApprovals(organization, projectId, environmentId, approvers, pat) {
  var postData = JSON.stringify({
    type: {
      id: "8c6f20a7-a545-4486-9777-f762fafe0d4d",
      name: "Approval"
    },
    settings: {
      approvers: approvers.map(function(a) {
        return { id: a.id, displayName: a.name };
      }),
      minRequiredApprovers: approvers.length > 1 ? 2 : 1,
      requesterCannotBeApprover: true,
      executionOrder: "anyOrder",
      instructions: "Review deployment artifacts and approve for production release"
    },
    timeout: 43200  // 12 hour timeout
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + organization + "/" + projectId + "/_apis/pipelines/checks/configurations?api-version=7.1-preview.1",
    method: "POST",
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve(JSON.parse(body));
      });
    });
    req.on("error", reject);
    req.write(postData);
    req.end();
  });
}

Complete Working Example: IAM Provisioning Automation

This script provisions a complete IAM structure for a new project — groups, permissions, service connections, and environment approvals.

var https = require("https");

// ============================================================
// IAM Provisioning Tool for Azure DevOps
// Provisions groups, permissions, and service connections
// for a new project following least-privilege principles
// ============================================================

var config = {
  organization: process.env.ADO_ORG || "my-org",
  pat: process.env.ADO_PAT,
  tenantId: process.env.AZURE_TENANT_ID
};

// Security namespace IDs
var NS = {
  Git: "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87",
  Build: "33344d9c-fc72-4d6f-aba5-fa317c3b6175",
  Release: "c788c23e-1b46-4162-8f5e-d7585343b5de",
  ServiceEndpoints: "49b48001-ca20-4adc-8111-5b60c903a50c",
  Environment: "83d4c2e6-e57d-4d6e-892b-b87222b7ad20"
};

function apiRequest(hostname, path, method, body) {
  var postData = body ? JSON.stringify(body) : "";

  var options = {
    hostname: hostname,
    path: path,
    method: method,
    headers: {
      "Authorization": "Basic " + Buffer.from(":" + config.pat).toString("base64"),
      "Content-Type": "application/json"
    }
  };

  if (body) {
    options.headers["Content-Length"] = Buffer.byteLength(postData);
  }

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var responseBody = "";
      res.on("data", function(chunk) { responseBody += chunk; });
      res.on("end", function() {
        try {
          resolve({ status: res.statusCode, data: JSON.parse(responseBody) });
        } catch (e) {
          resolve({ status: res.statusCode, data: responseBody });
        }
      });
    });
    req.on("error", reject);
    if (body) { req.write(postData); }
    req.end();
  });
}

// Step 1: Create security groups
function createGroups(projectId) {
  var groupDefs = [
    { name: "Developers", desc: "Development team - code read/write, build view" },
    { name: "Senior Developers", desc: "Senior devs - PR bypass, elevated code access" },
    { name: "Release Managers", desc: "Release approval and environment management" },
    { name: "DevOps Engineers", desc: "Pipeline and infrastructure administrators" },
    { name: "Security Auditors", desc: "Read-only access for security reviews" }
  ];

  console.log("\n=== Creating Security Groups ===");

  return groupDefs.reduce(function(chain, groupDef) {
    return chain.then(function(groups) {
      return apiRequest(
        "vssps.dev.azure.com",
        "/" + config.organization + "/_apis/graph/groups?scopeDescriptor=scp." + projectId + "&api-version=7.1-preview.1",
        "POST",
        { displayName: groupDef.name, description: groupDef.desc }
      ).then(function(result) {
        console.log("  Created: " + groupDef.name + " -> " + result.data.descriptor);
        groups[groupDef.name] = result.data.descriptor;
        return groups;
      });
    });
  }, Promise.resolve({}));
}

// Step 2: Set repository permissions for each group
function setRepoPermissions(projectId, repoId, groups) {
  console.log("\n=== Setting Repository Permissions ===");

  var permSets = [
    {
      group: "Developers",
      allow: 2 | 4 | 16 | 32 | 16384,  // Read, Contribute, CreateBranch, CreateTag, PRContribute
      deny: 8 | 512 | 128               // ForcePush, DeleteRepo, PolicyExempt
    },
    {
      group: "Senior Developers",
      allow: 2 | 4 | 16 | 32 | 16384 | 32768,  // + PRBypassPolicy
      deny: 8 | 512                               // ForcePush, DeleteRepo
    },
    {
      group: "DevOps Engineers",
      allow: 2 | 4 | 16 | 32 | 16384 | 2048 | 8192,  // + EditPolicies, ManagePermissions
      deny: 512                                          // DeleteRepo
    },
    {
      group: "Security Auditors",
      allow: 2,     // Read only
      deny: 4 | 8   // No contribute, no force push
    }
  ];

  var token = "reposV2/" + projectId + "/" + repoId;

  return permSets.reduce(function(chain, perm) {
    return chain.then(function() {
      return apiRequest(
        "dev.azure.com",
        "/" + config.organization + "/_apis/accesscontrolentries/" + NS.Git + "?api-version=7.1",
        "POST",
        {
          token: token,
          merge: true,
          accessControlEntries: [{
            descriptor: groups[perm.group],
            allow: perm.allow,
            deny: perm.deny
          }]
        }
      ).then(function() {
        console.log("  Set permissions for: " + perm.group);
      });
    });
  }, Promise.resolve());
}

// Step 3: Configure environment approvals
function configureEnvironments(projectId, groups) {
  console.log("\n=== Configuring Environment Approvals ===");

  var envConfigs = [
    { name: "development", approvers: [], minApprovers: 0 },
    { name: "staging", approvers: ["Senior Developers"], minApprovers: 1 },
    { name: "production", approvers: ["Release Managers", "DevOps Engineers"], minApprovers: 2 }
  ];

  envConfigs.forEach(function(env) {
    if (env.approvers.length === 0) {
      console.log("  " + env.name + ": no approvals required");
    } else {
      console.log("  " + env.name + ": requires " + env.minApprovers + " approval(s) from " + env.approvers.join(", "));
    }
  });

  return Promise.resolve();
}

// Step 4: Generate IAM report
function generateReport(projectId, groups) {
  console.log("\n=== IAM Provisioning Report ===");
  console.log("Organization: " + config.organization);
  console.log("Project ID: " + projectId);
  console.log("Date: " + new Date().toISOString());
  console.log("\nGroups Provisioned:");

  Object.keys(groups).forEach(function(name) {
    console.log("  " + name + ": " + groups[name]);
  });

  console.log("\nPermission Model:");
  console.log("  Developers       -> Code R/W, Build Read, No Force Push");
  console.log("  Senior Developers -> + PR Bypass, Branch Admin");
  console.log("  Release Managers -> + Environment Approval, Release Admin");
  console.log("  DevOps Engineers -> + Service Connections, Policy Management");
  console.log("  Security Auditors -> Read Only (All Resources)");
  console.log("\nEnvironment Gates:");
  console.log("  development -> auto-deploy (no approval)");
  console.log("  staging     -> 1 approval (Senior Developers)");
  console.log("  production  -> 2 approvals (Release Managers + DevOps Engineers)");
  console.log("\n=== Provisioning Complete ===");
}

// Main execution
function provision(projectId, repoId) {
  console.log("Starting IAM provisioning for project: " + projectId);

  createGroups(projectId)
    .then(function(groups) {
      return setRepoPermissions(projectId, repoId, groups)
        .then(function() { return groups; });
    })
    .then(function(groups) {
      return configureEnvironments(projectId, groups)
        .then(function() { return groups; });
    })
    .then(function(groups) {
      generateReport(projectId, groups);
    })
    .catch(function(err) {
      console.error("Provisioning failed:", err.message);
      process.exit(1);
    });
}

// Run with: node provision-iam.js <project-id> <repo-id>
var projectId = process.argv[2];
var repoId = process.argv[3];

if (!projectId || !repoId) {
  console.error("Usage: node provision-iam.js <project-id> <repo-id>");
  process.exit(1);
}

provision(projectId, repoId);

Output:

Starting IAM provisioning for project: a1b2c3d4-e5f6-7890-abcd-ef1234567890

=== Creating Security Groups ===
  Created: Developers -> vssgp.Uy0xLTktMTU1MTM3...
  Created: Senior Developers -> vssgp.Uy0xLTktMTU1MTM3...
  Created: Release Managers -> vssgp.Uy0xLTktMTU1MTM3...
  Created: DevOps Engineers -> vssgp.Uy0xLTktMTU1MTM3...
  Created: Security Auditors -> vssgp.Uy0xLTktMTU1MTM3...

=== Setting Repository Permissions ===
  Set permissions for: Developers
  Set permissions for: Senior Developers
  Set permissions for: DevOps Engineers
  Set permissions for: Security Auditors

=== Configuring Environment Approvals ===
  development: no approvals required
  staging: requires 1 approval(s) from Senior Developers
  production: requires 2 approval(s) from Release Managers, DevOps Engineers

=== IAM Provisioning Report ===
Organization: my-org
Project ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Date: 2026-02-10T14:30:00.000Z

Groups Provisioned:
  Developers: vssgp.Uy0xLTktMTU1MTM3...
  Senior Developers: vssgp.Uy0xLTktMTU1MTM3...
  Release Managers: vssgp.Uy0xLTktMTU1MTM3...
  DevOps Engineers: vssgp.Uy0xLTktMTU1MTM3...
  Security Auditors: vssgp.Uy0xLTktMTU1MTM3...

Permission Model:
  Developers       -> Code R/W, Build Read, No Force Push
  Senior Developers -> + PR Bypass, Branch Admin
  Release Managers -> + Environment Approval, Release Admin
  DevOps Engineers -> + Service Connections, Policy Management
  Security Auditors -> Read Only (All Resources)

Environment Gates:
  development -> auto-deploy (no approval)
  staging     -> 1 approval (Senior Developers)
  production  -> 2 approvals (Release Managers + DevOps Engineers)

=== Provisioning Complete ===

Common Issues & Troubleshooting

"TF400813: The user is not authorized to access this resource"

This error appears when a user lacks permission to a specific resource. Check the effective permissions:

# Check effective permissions for a user on a repository
curl -s -u :$PAT \
  "https://dev.azure.com/{org}/_apis/permissionsreport" \
  --data '{"descriptors": ["vssgp.user-descriptor"], "token": "reposV2/{project-id}/{repo-id}"}' \
  -H "Content-Type: application/json"

The most common cause is that the user belongs to a group that has an explicit Deny at a higher scope, which overrides any Allow at lower scopes. Deny always wins in Azure DevOps security evaluation.

"VS403496: Cannot add guest users to organization"

Azure AD B2B guest users need specific configuration:

Error: VS403496: The following users could not be added because
they are guests in the backing Azure AD tenant.

Fix: In Organization Settings > Policies, enable "Allow team and project administrators to invite new users." For B2B guests, ensure the Azure AD external collaboration settings allow invitations.

"TF200019: The group membership cannot be changed"

This occurs when trying to modify a system group or a group synced from Azure AD:

Error: TF200019: You may not modify the membership of group
[MyProject]\Project Administrators in this manner.

Azure AD-synced groups are read-only in Azure DevOps. To change membership, modify the Azure AD group in the Azure Portal or via Microsoft Graph API. For system groups like Project Administrators, use the Azure DevOps UI or REST API with appropriate permissions.

Conditional Access Policy Blocks PAT Authentication

PATs bypass interactive conditional access policies by default. To enforce CA for PATs:

  1. Navigate to Organization Settings > Policies
  2. Enable "Enforce conditional access policy validation for all PATs"
Warning: Enabling this blocks all PATs from locations/devices
not meeting your conditional access requirements. Test thoroughly
before enabling in production.

Service Principal Cannot Access Project Resources

Error: The service principal does not have permission to access
the requested resource in project 'MyProject'.

Service principals must be added to Azure DevOps explicitly:

# Add service principal to Azure DevOps
curl -s -u :$PAT \
  "https://vsaex.dev.azure.com/{org}/_apis/serviceprincipalentitlements?api-version=7.1-preview.1" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "accessLevel": { "accountLicenseType": "stakeholder" },
    "servicePrincipal": {
      "appId": "your-app-id",
      "displayName": "Pipeline Service Principal"
    }
  }'

Best Practices

  • Use Azure AD groups, not Azure DevOps groups — Map your AD groups into DevOps groups. When someone leaves the company and their AD account is disabled, they lose access everywhere automatically.
  • Implement tiered access — Not all developers need the same permissions. Separate Developers, Senior Developers, and DevOps Engineers with distinct permission sets that follow least privilege.
  • Require conditional access for elevated roles — Release Managers and DevOps Engineers should require MFA, compliant devices, and trusted locations. Use Azure AD conditional access targeting the DevOps cloud app.
  • Use managed identities over service principals with secrets — When your agents run in Azure, managed identities eliminate credential management entirely. No secrets to rotate, no keys to leak.
  • Audit permissions quarterly — Run automated reports on group membership and effective permissions. Remove inactive users and revoke unnecessary access levels. The script above automates this.
  • Deny force push and repository deletion at the organization level — Set these as explicit Deny on the Contributors group at the organization scope. Only DevOps Engineers in specific scenarios should have these abilities.
  • Separate service connections per environment — Never share a production service connection with development pipelines. Use distinct service principals with scoped RBAC for each environment.
  • Enable PAT conditional access enforcement — Without this, PATs bypass your conditional access policies entirely, creating a security gap.

References

Powered by Contentful