Skip to content
This repository was archived by the owner on Jul 25, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BuildTasks/triggerbuildtask/triggerbuildtaskV4/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"version": {
"Major": 4,
"Minor": 2,
"Patch": 0
"Patch": 1
},
"instanceNameFormat": "Trigger a new build of $(buildDefinition)",
"groups": [
Expand Down
98 changes: 98 additions & 0 deletions BuildTasks/triggerbuildtask/triggerbuildtaskV4/taskrunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export class TaskRunner {
userId: string;
sourceVersion: string;

// Holds the build api instance we patched to send the X-TFS-Impersonate header,
// so triggerBuilds() can switch impersonation on only around the queue call.
impersonationBuildApi: any = null;

tfsRestService: tfsService.ITfsRestService;
taskLibrary: tl.ITaskLibrary;
generalFunctions: common.IGeneralFunctions;
Expand Down Expand Up @@ -144,11 +148,73 @@ export class TaskRunner {
}
}

// Resolves the triggering user's legacy identity descriptor and patches the build api so that
// requests can carry the 'X-TFS-Impersonate' header. The header itself is only activated around
// the queue call (see triggerBuilds) so that condition checks and waiting keep running as the
// authenticating identity. Any failure here is non-fatal: we log a warning and fall back to the
// old behaviour (build attributed to the authenticating identity).
private async setupImpersonation(): Promise<void> {
if (this.userId === undefined || this.userId === "" || this.userId === "undefined") {
return;
}

try {
var webApi: any = (<any>this.tfsRestService).azureDevOpsWebApi;
var connection: any = webApi ? webApi.connection : null;
var buildApi: any = (<any>this.tfsRestService).vstsBuildApi;

if (!connection || !connection.rest || !buildApi || typeof buildApi.createRequestOptions !== "function") {
console.log(`##vso[task.logissue type=warning]Impersonation: could not access the REST connection/build api; the triggered build will run as the authenticating identity.`);
return;
}

// Resolve the legacy IdentityDescriptor ("type;identifier") for the triggering user's id.
var baseUrl: string = this.tfsServer.endsWith("/") ? this.tfsServer : `${this.tfsServer}/`;
var identitiesUrl: string = `${baseUrl}_apis/identities?identityIds=${this.userId}&api-version=6.0`;
var idResponse: any = await connection.rest.get(identitiesUrl);
var identities: any[] = (idResponse && idResponse.result) ? idResponse.result.value : null;

if (!identities || identities.length === 0 || !identities[0].descriptor) {
console.log(`##vso[task.logissue type=warning]Impersonation: could not resolve an identity descriptor for user id ${this.userId}; `
+ `the triggered build will run as the authenticating identity.`);
return;
}

var descriptor: string = identities[0].descriptor;
var displayName: string = identities[0].providerDisplayName || identities[0].customDisplayName || this.userId;

// Patch createRequestOptions so that, when activated, the impersonation header is added.
buildApi.__impersonateActive = false;
buildApi.__impersonateDescriptor = descriptor;
var originalCreateRequestOptions: any = buildApi.createRequestOptions.bind(buildApi);
buildApi.createRequestOptions = function (mediaType: string, apiVersion: string): any {
var options: any = originalCreateRequestOptions(mediaType, apiVersion);
if (buildApi.__impersonateActive && buildApi.__impersonateDescriptor) {
options.additionalHeaders = options.additionalHeaders || {};
options.additionalHeaders["X-TFS-Impersonate"] = buildApi.__impersonateDescriptor;
}
return options;
};

this.impersonationBuildApi = buildApi;
console.log(`Impersonation enabled: triggered build(s) will be queued on behalf of '${displayName}' (descriptor=${descriptor}).`);
console.log(`Note: this requires the authenticating identity to have the collection permission "Make requests on behalf of others".`);
} catch (err: any) {
console.log(`##vso[task.logissue type=warning]Impersonation setup failed (${err.message}); the triggered build will run as the authenticating identity.`);
}
}

private async triggerBuilds(): Promise<number[]> {
var queuedBuildIds: number[] = new Array();

var index: number = 0;

// Turn impersonation on only for the duration of the queue calls (if it was set up).
if (this.impersonationBuildApi) {
this.impersonationBuildApi.__impersonateActive = true;
}

try {
for (let build of this.buildDefinitionsToTrigger) {
var queuedBuild: Build =
await this.tfsRestService.triggerBuild(
Expand All @@ -165,13 +231,34 @@ export class TaskRunner {

console.log(`Queued new Build for definition ${build}: ${queuedBuild._links.web.href}`);

// When queuing on behalf of the triggering user, confirm the result (or warn if the
// server did not attribute the build to them - usually a missing impersonation permission).
if (this.queueBuildForUserThatTriggeredBuild
&& this.userId !== undefined && this.userId !== "" && this.userId !== "undefined"
&& queuedBuild.requestedFor) {
if (queuedBuild.requestedFor.id === this.userId) {
console.log(`Triggered build ${queuedBuild.id} is queued on behalf of ${queuedBuild.requestedFor.displayName}.`);
} else if (this.impersonationBuildApi) {
console.log(`##vso[task.logissue type=warning]Could not queue build ${queuedBuild.id} on behalf of the triggering user `
+ `(it was attributed to '${queuedBuild.requestedFor.displayName}'). Ensure the authenticating identity has the `
+ `collection permission "Make requests on behalf of others".`);
}
}

if (this.delayBetweenBuilds > 0 && index !== this.buildDefinitionsToTrigger.length - 1) {
console.log(`Waiting for ${this.delayBetweenBuilds} seconds before triggering next build`);
await this.generalFunctions.sleep(this.delayBetweenBuilds * 1000);
}

index++;
}
} finally {
// Always turn impersonation back off so that any later calls (waiting for builds,
// downloading artifacts, cancelling) run as the authenticating identity again.
if (this.impersonationBuildApi) {
this.impersonationBuildApi.__impersonateActive = false;
}
}

return queuedBuildIds;
}
Expand Down Expand Up @@ -287,6 +374,17 @@ export class TaskRunner {
this.userId = `${process.env[tfsService.RequestedForUserId]}`;
console.log(`Build shall be triggered for same user that triggered current build: ${user}`);
}

if (this.userId === undefined || this.userId === "" || this.userId === "undefined") {
console.log(`##vso[task.logissue type=warning]Could not resolve the triggering user's id (BUILD_REQUESTEDFORID / RELEASE_REQUESTEDFORID was empty). `
+ `The triggered build will fall back to the authenticating identity.`);
}

// Since Azure DevOps Server 2022.1 Patch 1+ / 2025 the 'requestedFor' body field is ignored
// when queuing. The only supported way to attribute the triggered build to the original user
// is server-side impersonation via the 'X-TFS-Impersonate' header, which requires the
// authenticating identity to hold the collection permission "Make requests on behalf of others".
await this.setupImpersonation();
}

if (this.useSameSourceVersion) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,122 @@ describe("Task Runner Tests", function (): void {
assert(consoleLogSpy.calledWith(`Build shall be triggered for same user that triggered current Release: ${ReleaseUserName}`));
});

it("should warn and still queue when impersonation cannot be set up against the service", async () => {
const UserName: string = "Buildy McBuildFace";
const UserID: string = "12";
const TriggeredBuildID: number = 1337;

// ensure build context (not release) regardless of what previous tests left behind
delete process.env[tfsService.ReleaseRequestedForUsername];
delete process.env[tfsService.ReleaseRequestedForId];
process.env[tfsService.RequestedForUsername] = UserName;
process.env[tfsService.RequestedForUserId] = UserID;

setupBuildConfiguration(["build"]);
tasklibraryMock.setup(tl => tl.getBoolInput(taskConstants.QueueBuildForUserInput, true))
.returns(() => true);

var triggeredBuild: any = {
id: TriggeredBuildID,
_links: { web: { href: "" } },
requestedFor: { displayName: "PAT Service User", id: "999" },
requestedBy: { displayName: "PAT Service User", id: "999" }
};
tfsRestServiceMock.setup(srv => srv.triggerBuild(
"build", TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(),
TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.returns(async () => triggeredBuild);

await subject.run();

// The mocked service exposes no REST connection, so setupImpersonation cannot patch the build api.
var warned: boolean = consoleLogSpy.getCalls().some(c =>
typeof c.args[0] === "string"
&& c.args[0].indexOf("##vso[task.logissue type=warning]") === 0
&& c.args[0].indexOf("could not access the REST connection") > -1);
assert(warned, "Expected a warning that impersonation could not be set up");

// The build should still be queued (impersonation failure is non-fatal).
tfsRestServiceMock.verify(srv => srv.triggerBuild(
"build", TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(),
TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()),
TypeMoq.Times.once());
});

it("should resolve the identity descriptor and inject the X-TFS-Impersonate header for the queue call", async () => {
const UserName: string = "Elior Levi";
const UserID: string = "76a352ff-d957-493f-9a87-8db7941e8441";
const Descriptor: string = "System.Security.Principal.WindowsIdentity;S-1-5-21-29812500-3243033545-702319832-56338";
const TriggeredBuildID: number = 4242;

delete process.env[tfsService.ReleaseRequestedForUsername];
delete process.env[tfsService.ReleaseRequestedForId];
process.env[tfsService.RequestedForUsername] = UserName;
process.env[tfsService.RequestedForUserId] = UserID;

setupBuildConfiguration(["build"]);
tasklibraryMock.setup(tl => tl.getBoolInput(taskConstants.QueueBuildForUserInput, true))
.returns(() => true);

// Fake build api whose createRequestOptions we expect the task to wrap.
var fakeBuildApi: any = {
createRequestOptions: function (mediaType: string, apiVersion: string): any {
return { acceptHeader: "application/json" };
}
};

// Capture the options produced for the queue call (i.e. while impersonation is active).
var capturedOptions: any = null;

// A hand-rolled service double exposing exactly the surface setupImpersonation / triggerBuilds use.
var fakeService: any = {
initialize: async () => { /* no-op */ },
azureDevOpsWebApi: {
connection: {
rest: {
get: async (url: string) => {
assert(url.indexOf(`identityIds=${UserID}`) > -1, "identity lookup url should query the user id");
return { result: { value: [{ descriptor: Descriptor, providerDisplayName: UserName }] } };
}
}
}
},
vstsBuildApi: fakeBuildApi,
triggerBuild: async () => {
// Simulate the SDK's queueBuild building its request options while impersonation is on.
capturedOptions = fakeBuildApi.createRequestOptions("application/json", "7.1");
return {
id: TriggeredBuildID,
_links: { web: { href: "" } },
requestedFor: { displayName: UserName, id: UserID },
requestedBy: { displayName: UserName, id: UserID }
};
}
};

var impersonatingSubject: tr.TaskRunner = new tr.TaskRunner(
fakeService, tasklibraryMock.object, generalFunctionsMock.object);

await impersonatingSubject.run();

assert(capturedOptions !== null, "expected the build api createRequestOptions to be called during the queue");
assert(capturedOptions.additionalHeaders !== undefined, "expected additionalHeaders to be set");
assert.strictEqual(capturedOptions.additionalHeaders["X-TFS-Impersonate"], Descriptor,
"expected the X-TFS-Impersonate header to carry the resolved descriptor during the queue call");

// Impersonation must be turned off again after the queue loop.
assert.strictEqual(fakeBuildApi.__impersonateActive, false, "impersonation flag should be reset after queueing");

// And it should have logged that impersonation was enabled with the resolved descriptor.
var enabledLogged: boolean = consoleLogSpy.getCalls().some(c =>
typeof c.args[0] === "string" && c.args[0].indexOf("Impersonation enabled") > -1 && c.args[0].indexOf(Descriptor) > -1);
assert(enabledLogged, "expected an 'Impersonation enabled' log line with the descriptor");

// And the concise on-behalf-of confirmation for the queued build.
assert(consoleLogSpy.calledWith(`Triggered build ${TriggeredBuildID} is queued on behalf of ${UserName}.`),
"expected the concise 'queued on behalf of' confirmation");
});

it("should NOT trigger build for user that trigger original build if not configured", async () => {
const UserName: string = "Buildy McBuildFace";
const UserID: string = "12";
Expand Down
2 changes: 1 addition & 1 deletion BuildTasks/vss-extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifestVersion": 1,
"id": "tfs-extensions-build-tasks",
"name": "Trigger Build Task",
"version": "4.2.1",
"version": "4.2.2",
"publisher": "benjhuser",
"targets": [
{
Expand Down