Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ private async Task ApplyStartContextIfPresent(CancellationToken cancellationToke
{
_logger.LogDebug("Applying host context");

var encryptedAssignmentContext = JsonConvert.DeserializeObject<EncryptedHostAssignmentContext>(startContext);
var assignmentContext = _startupContextProvider.SetContext(encryptedAssignmentContext);
var hostAssignmentRequest = JsonConvert.DeserializeObject<HostAssignmentRequest>(startContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above TryGetStartContextOrNullAsync virtual call has a different implementation on Legion and Atlas. This same hosted service code is running on each SKU. Just want to confirm that there isn't any breaking format change here - the persisted assignment context is always encrypted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried for legion changes by giving encrypted and unencrypted payload for controller and it is working fine. Means encrypted path of controller which is existing is working fine.

Any way to test this storage path?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just pointing out how the system behaves. Likely things are OK, since up to this point only encrypted was supported, and your new deserialization supports both old and new formats. I'm just this out so you can think about this and ensure no breaks. Bala knows the context storage logic the best so you might have him sign off on this as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Will check with Bala

Copy link
Contributor Author

@manikantanallagatla manikantanallagatla Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage one is only used in CV1 on ACI path(Atlas). We are not changing anything wrt encryption payload. So, storage path should not be affected by this repo. They should continue the encrypted payload and no changes needed.

For legion path, technically it should work as we are storing the volume in https://msazure.visualstudio.com/One/_git/AAPT-Antares-Functions-Docker?path=/images/flexconsumption/components/appserver/cv2/pkg/instance/instance.go&version=GBdev&line=76&lineEnd=77&lineStartColumn=1&lineEndColumn=1&lineStyle=plain&_a=contents

I will check legion for both encrypted and unencrypted path

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have tested both paths and they are working fine.

var assignmentContext = _startupContextProvider.SetContext(hostAssignmentRequest);
await SpecializeMSISideCar(assignmentContext);

try
Expand Down
33 changes: 29 additions & 4 deletions src/WebJobs.Script.WebHost/Controllers/InstanceController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System.Threading.Tasks;
Expand All @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs.Script.WebHost.Management;
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authentication;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -35,11 +36,35 @@ public InstanceController(IEnvironment environment, IInstanceManager instanceMan
[HttpPost]
[Route("admin/instance/assign")]
[Authorize(Policy = PolicyNames.AdminAuthLevel)]
public async Task<IActionResult> Assign([FromBody] EncryptedHostAssignmentContext encryptedAssignmentContext)
public async Task<IActionResult> Assign([FromBody] HostAssignmentRequest hostAssignmentRequest)
{
_logger.LogDebug($"Starting container assignment for host : {Request?.Host}. ContextLength is: {encryptedAssignmentContext.EncryptedContext?.Length}");
if (string.IsNullOrEmpty(hostAssignmentRequest.EncryptedContext) &&
hostAssignmentRequest.AssignmentContext is null)
{
return BadRequest($"At least one of {nameof(HostAssignmentRequest.AssignmentContext)} or {nameof(HostAssignmentRequest.EncryptedContext)} must be provided.");
}

if (!string.IsNullOrEmpty(hostAssignmentRequest.EncryptedContext) &&
hostAssignmentRequest.AssignmentContext is not null)
{
return BadRequest($"Only one of {nameof(HostAssignmentRequest.AssignmentContext)} or {nameof(HostAssignmentRequest.EncryptedContext)} may be set.");
}

if (!string.IsNullOrEmpty(hostAssignmentRequest.EncryptedContext))
{
_logger.LogDebug("Starting container assignment. ContextLength is {ContextLength}", hostAssignmentRequest.EncryptedContext.Length);
}
else
{
if (!User.HasClaim(SecurityConstants.AssignUnencryptedClaimType, "true"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this method do case sensitive check? Would that be a concern?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are adding the string in ScriptJwtBearerExtensions and checking here. So, we dont need case sensitive check right

{
_logger.LogWarning("Required claims missing for invoking unencrypted assignment");
return Forbid();
}
_logger.LogDebug("Starting container assignment.");
}

var assignmentContext = _startupContextProvider.SetContext(encryptedAssignmentContext);
var assignmentContext = _startupContextProvider.SetContext(hostAssignmentRequest);

// before starting the assignment we want to perform as much
// up front validation on the context as possible
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

namespace Microsoft.Azure.WebJobs.Script.WebHost.Models
{
public class EncryptedHostAssignmentContext
public class HostAssignmentRequest
{
[JsonProperty("encryptedContext")]
[JsonProperty("encryptedContext", DefaultValueHandling = DefaultValueHandling.Ignore)]
public string EncryptedContext { get; set; }

[JsonProperty("assignmentContext", DefaultValueHandling = DefaultValueHandling.Ignore)]
public HostAssignmentContext AssignmentContext { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -72,6 +72,11 @@ private static void ConfigureOptions(JwtBearerOptions options)
claims.Add(new Claim(SecurityConstants.InvokeClaimType, "true"));
}

if (string.Equals(c.SecurityToken.Issuer, ScriptConstants.LegionCoreUri, StringComparison.OrdinalIgnoreCase))
{
claims.Add(new Claim(SecurityConstants.AssignUnencryptedClaimType, "true"));
}

c.Principal.AddIdentity(new ClaimsIdentity(claims));
c.Success();

Expand Down Expand Up @@ -149,6 +154,7 @@ public static TokenValidationParameters CreateTokenValidationParameters()
result.ValidIssuers =
[
AppServiceCoreUri,
LegionCoreUri,
string.Format(ScmSiteUriFormat, ScriptSettingsManager.Instance.GetSetting(AzureWebsiteName)),
string.Format(SiteUriFormat, ScriptSettingsManager.Instance.GetSetting(AzureWebsiteName))
];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
Expand All @@ -20,5 +20,10 @@ public class SecurityConstants
/// /admin/functions/{function} API.
/// </summary>
public const string InvokeClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/invoke";

/// <summary>
/// Claim indicating whether a principal is authorized to invoke assign with an unencrypted payload.
/// </summary>
public const string AssignUnencryptedClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/assign-unencrypted";
}
}
21 changes: 13 additions & 8 deletions src/WebJobs.Script.WebHost/StartupContextProvider.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -132,15 +132,20 @@ private StartupContext GetStartupContextOrNull()
}

/// <summary>
/// Decrypt and deserialize the specified context, and apply values from it to the
/// startup cache context.
/// Applies the values from the specified assignment request to the startup cache context,
/// performing any required decryption.
/// </summary>
/// <param name="encryptedContext">The encrypted assignment context.</param>
/// <returns>The decrypted assignment context</returns>
public virtual HostAssignmentContext SetContext(EncryptedHostAssignmentContext encryptedContext)
/// <param name="hostAssignmentRequest">The Host assignment request.</param>
/// <returns>The assignment context applied.</returns>
public virtual HostAssignmentContext SetContext(HostAssignmentRequest hostAssignmentRequest)
{
string decryptedContext = EncryptionHelper.Decrypt(encryptedContext.EncryptedContext, environment: _environment);
var hostAssignmentContext = JsonConvert.DeserializeObject<HostAssignmentContext>(decryptedContext);
var hostAssignmentContext = hostAssignmentRequest.AssignmentContext;

if (!string.IsNullOrEmpty(hostAssignmentRequest.EncryptedContext))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if you shouldn't add a clause hostAssignmentContext == null to this if, making it clear that only one of these should ever be specified. As opposed to the precedence handling you have here, where both can be specified, but encrypted wins.

I realize in the controller method you do validation, but there's another path where this method is called, where the request is pulled from storage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how the storage path works. But shall we move the handling that only one needs to be specified here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are sure storage account path only takes encrypted payload, we can have method overloading for SetContext method. One which takes encrypted payload and other that takes unencrypted payload

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage path where context is pulled from storage MUST be encrypted since data must be encrypted at rest.

Copy link
Contributor

@jviau jviau Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage path where context is pulled from storage MUST be encrypted since data must be encrypted at rest.

This is a bit besides the point, but is this the customer's storage account? The encrypted at rest requirement here would be the storage account itself satisfying that. All data in a storage account is already "encrypted at rest": https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption.

The only time we, the Functions Host team, would have an "encrypted at rest" requirement is if we were offering some storage solution which did not already have encrypted-at-rest features.

But back to the main point: I agree that if we expect storage scenario to always have client-side encryption (which is what this is), then we should assert that as necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage is only used in CV1 on ACI path. We are not touching that path. So, storage wise, no change with this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think no changes are needed for storage and current validations in StartupContextProvider.cs seems fine

{
string decryptedContext = EncryptionHelper.Decrypt(hostAssignmentRequest.EncryptedContext, environment: _environment);
hostAssignmentContext = JsonConvert.DeserializeObject<HostAssignmentContext>(decryptedContext);
}

// Don't update StartupContext for warmup requests
if (!hostAssignmentContext.IsWarmupRequest)
Expand Down
1 change: 1 addition & 0 deletions src/WebJobs.Script/ScriptConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ public static class ScriptConstants
public const string ScmSiteUriFormat = "https://{0}.scm.azurewebsites.net";
public const string SiteUriFormat = "https://{0}.azurewebsites.net";
public const string AppServiceCoreUri = "https://appservice.core.azurewebsites.net";
public const string LegionCoreUri = "https://legion.core.azurewebsites.net";

public const string AzureFunctionsSystemDirectoryName = ".azurefunctions";
public const string HttpMethodConstraintName = "httpMethod";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ public async Task StandbyModeE2E_LinuxConsumptionOnLegion()
Assert.Equal(typeof(LinuxContainerLegionMetricsPublisher), webHost.Services.GetRequiredService<IMetricsPublisher>().GetType());
}

[Fact]
public async Task StandbyModeE2E_LinuxContainer()
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task StandbyModeE2E_LinuxContainer(bool useEncryptedPayload)
{
byte[] bytes = TestHelpers.GenerateKeyBytes();
var encryptionKey = Convert.ToBase64String(bytes);
Expand Down Expand Up @@ -114,7 +116,7 @@ public async Task StandbyModeE2E_LinuxContainer()
await VerifyWarmupSucceeds(restart: true);

// now specialize the site
await Assign(encryptionKey);
await Assign(encryptionKey, useEncryptedPayload);

// immediately call a function - expect the call to block until
// the host is fully specialized
Expand Down Expand Up @@ -240,7 +242,7 @@ public async Task LinuxContainer_TimeZoneEnvVariableE2E()

}

private async Task Assign(string encryptionKey)
private async Task Assign(string encryptionKey, bool useEncryptedPayload)
{
// create a zip package
var testFunctionPath = Path.Combine("TestScripts", "Node", "HttpTrigger");
Expand All @@ -260,32 +262,36 @@ private async Task Assign(string encryptionKey)
string uri = "admin/instance/assign";
var request = new HttpRequestMessage(HttpMethod.Post, uri);
var environment = new Dictionary<string, string>()
{
{ EnvironmentSettingNames.AzureWebsiteZipDeployment, sasUri.ToString() },
{ RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName, "~2" },
{ EnvironmentSettingNames.FunctionWorkerRuntime, "node" }
};
{
{ EnvironmentSettingNames.AzureWebsiteZipDeployment, sasUri.ToString() },
{ RpcWorkerConstants.FunctionWorkerRuntimeVersionSettingName, "~2" },
{ EnvironmentSettingNames.FunctionWorkerRuntime, "node" }
};
var assignmentContext = new HostAssignmentContext
{
SiteId = 1234,
SiteName = "TestApp",
Environment = environment
};
var encryptedAssignmentContext = CreateEncryptedContext(assignmentContext, encryptionKey);
var encryptedAssignmentContext = CreateHostAssignmentRequest(assignmentContext, encryptionKey, useEncryptedPayload);
string json = JsonConvert.SerializeObject(encryptedAssignmentContext);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
request.Headers.Add(AuthenticationLevelHandler.FunctionsKeyHeaderName, masterKey);
var response = await _httpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

private static EncryptedHostAssignmentContext CreateEncryptedContext(HostAssignmentContext context, string key)
private static HostAssignmentRequest CreateHostAssignmentRequest(HostAssignmentContext context, string key, bool useEncryptedPayload)
{
if (!useEncryptedPayload)
{
return new HostAssignmentRequest { AssignmentContext = context };
}

string json = JsonConvert.SerializeObject(context);
var encryptionKey = Convert.FromBase64String(key);
string encrypted = EncryptionHelper.Encrypt(json, encryptionKey);

return new EncryptedHostAssignmentContext { EncryptedContext = encrypted };
return new HostAssignmentRequest { EncryptedContext = encrypted };
}
}
}
Loading
Loading