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:
- Navigate to Azure Active Directory > Security > Conditional Access
- Create a new policy targeting the Azure DevOps cloud app (application ID:
499b84ac-1321-427f-aa17-267ca6975798) - 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:
- Navigate to Organization Settings > Policies
- 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.