From 7272910347f1fc5258d187c437f8ebbc8385e494 Mon Sep 17 00:00:00 2001 From: Elior Levi Date: Thu, 23 Jul 2026 11:36:19 +0300 Subject: [PATCH] TriggerBuild V4: attribute triggered build to the original user via impersonation Since Azure DevOps Server 2022.1 Patch 1 (and 2025), the server ignores the `requestedFor` field when queuing a build for security reasons, so the "Queue Build for user that triggered original build" option no longer works - the triggered build is always attributed to the authenticating identity (e.g. Project Collection Build Service). See issue #263. This restores the behaviour using the supported server-side impersonation mechanism: - Resolve the triggering user's legacy IdentityDescriptor from BUILD_REQUESTEDFORID (or RELEASE_REQUESTEDFORID) via the Identities API. - Send it in the `X-TFS-Impersonate` header on the queue request, scoped to the queue call only (condition checks and build-waiting keep running as the authenticating identity). - Requires the authenticating identity to hold the collection permission "Make requests on behalf of others". The change is non-fatal: if the descriptor can't be resolved or the connection can't be reached, it logs a warning and falls back to the previous behaviour, so it is safe where impersonation is unavailable (e.g. Azure DevOps Services). Adds unit tests covering descriptor resolution, header injection scoped to the queue call, and the graceful-fallback path. Co-Authored-By: Claude Opus 4.8 --- .../triggerbuildtaskV4/task.json | 2 +- .../triggerbuildtaskV4/taskrunner.ts | 98 +++++++++++++++ .../tests/taskRunnerTests.ts | 116 ++++++++++++++++++ BuildTasks/vss-extension.json | 2 +- 4 files changed, 216 insertions(+), 2 deletions(-) diff --git a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/task.json b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/task.json index b4b5d89..d02868a 100644 --- a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/task.json +++ b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/task.json @@ -12,7 +12,7 @@ "version": { "Major": 4, "Minor": 2, - "Patch": 0 + "Patch": 1 }, "instanceNameFormat": "Trigger a new build of $(buildDefinition)", "groups": [ diff --git a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/taskrunner.ts b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/taskrunner.ts index 47ea199..c47c773 100644 --- a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/taskrunner.ts +++ b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/taskrunner.ts @@ -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; @@ -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 { + if (this.userId === undefined || this.userId === "" || this.userId === "undefined") { + return; + } + + try { + var webApi: any = (this.tfsRestService).azureDevOpsWebApi; + var connection: any = webApi ? webApi.connection : null; + var buildApi: 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 { 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( @@ -165,6 +231,20 @@ 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); @@ -172,6 +252,13 @@ export class TaskRunner { 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; } @@ -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) { diff --git a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/tests/taskRunnerTests.ts b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/tests/taskRunnerTests.ts index a4465ee..606cbb0 100644 --- a/BuildTasks/triggerbuildtask/triggerbuildtaskV4/tests/taskRunnerTests.ts +++ b/BuildTasks/triggerbuildtask/triggerbuildtaskV4/tests/taskRunnerTests.ts @@ -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"; diff --git a/BuildTasks/vss-extension.json b/BuildTasks/vss-extension.json index 20d2780..46519e4 100644 --- a/BuildTasks/vss-extension.json +++ b/BuildTasks/vss-extension.json @@ -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": [ {