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": [ {