Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ public static void Map(RouteGroupBuilder group)
group.MapGet("/{serviceId}/bindings", HandleGetAsync);
}

// All four handlers share the same shape:
// 1. Resolve authenticated context once.
// 2. Validate body identity against claims (returns 400 OWNER_*_CONFLICT
// / BOUND_SERVICE_IDENTITY_CONFLICT before the more generic 403).
// 3. TryResolveContext / TryResolveIdentity using the already-resolved auth context
// (avoids the double-Resolve cost).
// 4. Dispatch the command / query.
private static async Task<IResult> HandleCreateAsync(
HttpContext http,
string serviceId,
Expand All @@ -29,22 +36,26 @@ private static async Task<IResult> HandleCreateAsync(
[FromServices] IServiceGovernanceCommandPort commandPort,
CancellationToken ct)
{
if (ServiceIdentityEndpointAccess.TryResolveContext(
var authenticatedContext = identityResolver.Resolve();
if (TryValidateOwnerIdentity(request.TenantId, request.AppId, request.Namespace, authenticatedContext) is { } ownerInvalid)
return ownerInvalid;

var bindingKind = ParseBindingKind(request.BindingKind);
if (TryValidateBoundServiceIdentity(bindingKind, request, authenticatedContext) is { } invalid)
return invalid;

if (!ServiceIdentityEndpointAccess.TryResolveContext(
identityResolver,
authenticatedContext,
request.TenantId,
request.AppId,
request.Namespace,
out var ownerContext,
out var denied) == false)
out var denied))
{
return denied;
}

var authenticatedContext = identityResolver.Resolve();
var bindingKind = ParseBindingKind(request.BindingKind);
if (TryValidateBoundServiceIdentity(bindingKind, request, authenticatedContext) is { } invalid)
return invalid;

var receipt = await commandPort.CreateBindingAsync(new CreateServiceBindingCommand
{
Spec = ToSpec(serviceId, request, request.BindingId ?? string.Empty, bindingKind, ownerContext, authenticatedContext),
Expand All @@ -61,22 +72,26 @@ private static async Task<IResult> HandleUpdateAsync(
[FromServices] IServiceGovernanceCommandPort commandPort,
CancellationToken ct)
{
if (ServiceIdentityEndpointAccess.TryResolveContext(
var authenticatedContext = identityResolver.Resolve();
if (TryValidateOwnerIdentity(request.TenantId, request.AppId, request.Namespace, authenticatedContext) is { } ownerInvalid)
return ownerInvalid;

var bindingKind = ParseBindingKind(request.BindingKind);
if (TryValidateBoundServiceIdentity(bindingKind, request, authenticatedContext) is { } invalid)
return invalid;

if (!ServiceIdentityEndpointAccess.TryResolveContext(
identityResolver,
authenticatedContext,
request.TenantId,
request.AppId,
request.Namespace,
out var ownerContext,
out var denied) == false)
out var denied))
{
return denied;
}

var authenticatedContext = identityResolver.Resolve();
var bindingKind = ParseBindingKind(request.BindingKind);
if (TryValidateBoundServiceIdentity(bindingKind, request, authenticatedContext) is { } invalid)
return invalid;

var receipt = await commandPort.UpdateBindingAsync(new UpdateServiceBindingCommand
{
Spec = ToSpec(serviceId, request, bindingId, bindingKind, ownerContext, authenticatedContext),
Expand All @@ -93,8 +108,13 @@ private static async Task<IResult> HandleRetireAsync(
[FromServices] IServiceGovernanceCommandPort commandPort,
CancellationToken ct)
{
var authenticatedContext = identityResolver.Resolve();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Retire 和 Get 这里是先 validate 再 resolve,但 Create (line 43-44) 和 Update (line 78-79) 是反过来——先 TryResolveContext 再 validate。四个 handler 的顺序不统一,读起来要回头比对。

功能是等价的(malformed claims 时 Resolve() 返回 null,validation 自然 no-op),但建议四个都对齐到 "先 validate 再 resolve":具体的 400 OWNER_SERVICE_IDENTITY_CONFLICT 比通用的 403 SERVICE_IDENTITY_ACCESS_DENIED 更早返回,给客户端的诊断信息也更精确。

顺带:Resolve() 现在在每个 handler 里被调两次(一次进 helper 内部,一次本地拿 authenticatedContext)。可以让 TryResolveContext / TryResolveIdentity 顺手 out ServiceIdentityContext? authenticatedContext,handler 直接用,少一次 claim 解析。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6860b02. All four handlers now share the same shape: (1) resolve authenticatedContext once, (2) validate body identity → 400 specific-code on mismatch, (3) TryResolveContext / TryResolveIdentity passing the already-resolved auth context (avoids the double Resolve() call), (4) dispatch. So HandleCreateAsync and HandleUpdateAsync now match HandleRetireAsync and HandleGetAsync — validate-then-resolve, and 400 OWNER_SERVICE_IDENTITY_CONFLICT precedes 403 SERVICE_IDENTITY_ACCESS_DENIED for malformed-claims-with-spoofed-body.

Added overloads TryResolveContext(resolver, authenticatedContext, ...) and TryResolveIdentity(resolver, authenticatedContext, ...) to ServiceIdentityEndpointAccess. Old overloads delegate into the new ones, so other callers (ServiceServingEndpoints, ServiceEndpoints, ServicePolicyEndpoints, ServiceEndpointCatalogEndpoints, …) keep compiling unchanged.

if (TryValidateOwnerIdentity(request.TenantId, request.AppId, request.Namespace, authenticatedContext) is { } ownerInvalid)
return ownerInvalid;

if (!ServiceIdentityEndpointAccess.TryResolveIdentity(
identityResolver,
authenticatedContext,
request.TenantId,
request.AppId,
request.Namespace,
Expand All @@ -121,8 +141,13 @@ private static async Task<IResult> HandleGetAsync(
[FromServices] IServiceGovernanceQueryPort queryPort,
CancellationToken ct)
{
var authenticatedContext = identityResolver.Resolve();
if (TryValidateOwnerIdentity(query.TenantId, query.AppId, query.Namespace, authenticatedContext) is { } ownerInvalid)
return ownerInvalid;

if (!ServiceIdentityEndpointAccess.TryResolveIdentity(
identityResolver,
authenticatedContext,
query.TenantId,
query.AppId,
query.Namespace,
Expand Down Expand Up @@ -190,6 +215,29 @@ private static ServiceBindingSpec ToSpec(
return spec;
}

private static IResult? TryValidateOwnerIdentity(
string? requestedTenantId,
string? requestedAppId,
string? requestedNamespace,
ServiceIdentityContext? authenticatedContext)
{
if (authenticatedContext is null)
return null;

if (!MatchesAuthenticatedValue(requestedTenantId, authenticatedContext.TenantId) ||
!MatchesAuthenticatedValue(requestedAppId, authenticatedContext.AppId) ||
!MatchesAuthenticatedValue(requestedNamespace, authenticatedContext.Namespace))
{
return Results.BadRequest(new
{
code = "OWNER_SERVICE_IDENTITY_CONFLICT",
message = "Authenticated service identity does not allow overriding owner tenantId, appId, or namespace.",
});
}

return null;
}

private static IResult? TryValidateBoundServiceIdentity(
ServiceBindingKind bindingKind,
ServiceBindingHttpRequest request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,25 @@ private static bool TryGetSingleClaimValue(

public static class ServiceIdentityEndpointAccess
{
/// <summary>
/// Resolves the owner identity context. When <paramref name="authenticatedContext"/> is
/// supplied (the caller already invoked <c>resolver.Resolve()</c>), claim resolution is
/// reused — avoiding the double-Resolve cost when the handler also needs the authenticated
/// context for validation (e.g., <c>TryValidateOwnerIdentity</c>). Pass <c>null</c> to fall
/// through to the original behaviour: when authenticated but claims are missing/ambiguous,
/// returns <c>403 SERVICE_IDENTITY_ACCESS_DENIED</c>; when unauthenticated, falls back to
/// the request's tenant/app/namespace fields.
/// </summary>
public static bool TryResolveContext(
IServiceIdentityContextResolver resolver,
ServiceIdentityContext? authenticatedContext,
string? fallbackTenantId,
string? fallbackAppId,
string? fallbackNamespace,
out ServiceIdentityContext context,
out IResult denied)
{
if (resolver.Resolve() is { } resolved)
if (authenticatedContext is { } resolved)
{
context = resolved;
denied = Results.Empty;
Expand Down Expand Up @@ -130,8 +140,25 @@ public static bool TryResolveContext(
return true;
}

public static bool TryResolveContext(
IServiceIdentityContextResolver resolver,
string? fallbackTenantId,
string? fallbackAppId,
string? fallbackNamespace,
out ServiceIdentityContext context,
out IResult denied)
=> TryResolveContext(
resolver,
resolver.Resolve(),
fallbackTenantId,
fallbackAppId,
fallbackNamespace,
out context,
out denied);

public static bool TryResolveIdentity(
IServiceIdentityContextResolver resolver,
ServiceIdentityContext? authenticatedContext,
string? fallbackTenantId,
string? fallbackAppId,
string? fallbackNamespace,
Expand All @@ -141,6 +168,7 @@ public static bool TryResolveIdentity(
{
if (!TryResolveContext(
resolver,
authenticatedContext,
fallbackTenantId,
fallbackAppId,
fallbackNamespace,
Expand All @@ -160,4 +188,22 @@ public static bool TryResolveIdentity(
};
return true;
}

public static bool TryResolveIdentity(
IServiceIdentityContextResolver resolver,
string? fallbackTenantId,
string? fallbackAppId,
string? fallbackNamespace,
string serviceId,
out ServiceIdentity identity,
out IResult denied)
=> TryResolveIdentity(
resolver,
resolver.Resolve(),
fallbackTenantId,
fallbackAppId,
fallbackNamespace,
serviceId,
out identity,
out denied);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Aevatar.Authentication.Abstractions;
using Aevatar.GAgentService.Abstractions;
using Aevatar.GAgentService.Abstractions.Commands;
Expand Down Expand Up @@ -31,9 +32,9 @@ public async Task BindingEndpoints_WhenAuthenticatedBoundServiceOmitsIdentity_Sh
{
Content = JsonContent.Create(new
{
tenantId = "spoof-tenant",
appId = "spoof-app",
@namespace = "spoof-ns",
tenantId = "tenant-claim",
appId = "app-claim",
@namespace = "ns-claim",
bindingId = "binding-a",
displayName = "Dependency",
bindingKind = "service",
Expand Down Expand Up @@ -68,6 +69,108 @@ public async Task BindingEndpoints_WhenAuthenticatedBoundServiceOmitsIdentity_Sh
});
}

[Fact]
public async Task BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsWithClaims_ShouldReturnBadRequest()
{
await using var host = await GovernanceEndpointTestHost.StartAsync();

using var request = new HttpRequestMessage(HttpMethod.Post, "/api/services/orders/bindings")
{
Content = JsonContent.Create(new
{
tenantId = "spoof-tenant",
appId = "spoof-app",
@namespace = "spoof-ns",
bindingId = "binding-a",
displayName = "Dependency",
bindingKind = "service",
service = new
{
serviceId = "dependency",
endpointId = "run",
},
}),
};
AddAuthenticatedClaims(request);

var response = await host.Client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await ReadCodeAsync(response)).Should().Be("OWNER_SERVICE_IDENTITY_CONFLICT");
host.CommandPort.CreateBindingCommand.Should().BeNull();
}

[Fact]
public async Task BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsOnUpdate_ShouldReturnBadRequest()
{
await using var host = await GovernanceEndpointTestHost.StartAsync();

using var request = new HttpRequestMessage(HttpMethod.Put, "/api/services/orders/bindings/binding-a")
{
Content = JsonContent.Create(new
{
tenantId = "spoof-tenant",
appId = "spoof-app",
@namespace = "spoof-ns",
bindingId = "binding-a",
displayName = "Dependency",
bindingKind = "service",
service = new
{
serviceId = "dependency",
endpointId = "run",
},
}),
};
AddAuthenticatedClaims(request);

var response = await host.Client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await ReadCodeAsync(response)).Should().Be("OWNER_SERVICE_IDENTITY_CONFLICT");
host.CommandPort.UpdateBindingCommand.Should().BeNull();
}

[Fact]
public async Task BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsOnRetire_ShouldReturnBadRequest()
{
await using var host = await GovernanceEndpointTestHost.StartAsync();

using var request = new HttpRequestMessage(HttpMethod.Post, "/api/services/orders/bindings/binding-a:retire")
{
Content = JsonContent.Create(new
{
tenantId = "spoof-tenant",
appId = "spoof-app",
@namespace = "spoof-ns",
}),
};
AddAuthenticatedClaims(request);

var response = await host.Client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await ReadCodeAsync(response)).Should().Be("OWNER_SERVICE_IDENTITY_CONFLICT");
host.CommandPort.RetireBindingCommand.Should().BeNull();
}

[Fact]
public async Task BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsWithQuery_ShouldReturnBadRequest()
{
await using var host = await GovernanceEndpointTestHost.StartAsync();

using var request = new HttpRequestMessage(
HttpMethod.Get,
"/api/services/orders/bindings?tenantId=spoof-tenant&appId=spoof-app&namespace=spoof-ns");
AddAuthenticatedClaims(request);

var response = await host.Client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await ReadCodeAsync(response)).Should().Be("OWNER_SERVICE_IDENTITY_CONFLICT");
host.QueryPort.LastBindingsIdentity.Should().BeNull();
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

两个新 conflict 测试都只断言了 status code 和 command/query 没被分发,但没断言 response body 里 code == "OWNER_SERVICE_IDENTITY_CONFLICT"。这个 code 是 API 契约里客户端会 switch 的字段——message 字符串可以变,code 不能变。建议加一条:

var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("code").GetString().Should().Be("OWNER_SERVICE_IDENTITY_CONFLICT");

(同样的缺口 BOUND_SERVICE_IDENTITY_CONFLICT 那条测试也有,但 forward-fix 在这里更便宜。)

另:这两个测试后面缺 PUT 和 :retire 的 conflict 场景,HandleUpdateAsync / HandleRetireAsync 里加的 TryValidateOwnerIdentity 实际上没被集成测试覆盖到。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6860b02. The two existing owner-conflict tests (POST body, GET query) now assert code == "OWNER_SERVICE_IDENTITY_CONFLICT", and the bound-service test gets the same forward-fix asserting BOUND_SERVICE_IDENTITY_CONFLICT. Added two new integration scenarios that previously had zero coverage:

  • BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsOnUpdate_ShouldReturnBadRequest (PUT /bindings/{id})
  • BindingEndpoints_WhenAuthenticatedOwnerIdentityConflictsOnRetire_ShouldReturnBadRequest (POST /bindings/{id}:retire)

Both assert 400 + OWNER_SERVICE_IDENTITY_CONFLICT + the corresponding command port stayed null. The shared setup is now collapsed into AddAuthenticatedClaims and ReadCodeAsync helpers.


[Fact]
public async Task BindingEndpoints_WhenAuthenticatedBoundServiceIdentityConflictsWithClaims_ShouldReturnBadRequest()
{
Expand All @@ -93,17 +196,29 @@ public async Task BindingEndpoints_WhenAuthenticatedBoundServiceIdentityConflict
},
}),
};
request.Headers.Add("X-Test-Authenticated", "true");
request.Headers.Add("X-Test-Tenant-Id", "tenant-claim");
request.Headers.Add("X-Test-App-Id", "app-claim");
request.Headers.Add("X-Test-Namespace", "ns-claim");
AddAuthenticatedClaims(request);

var response = await host.Client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await ReadCodeAsync(response)).Should().Be("BOUND_SERVICE_IDENTITY_CONFLICT");
host.CommandPort.CreateBindingCommand.Should().BeNull();
}

private static void AddAuthenticatedClaims(HttpRequestMessage request)
{
request.Headers.Add("X-Test-Authenticated", "true");
request.Headers.Add("X-Test-Tenant-Id", "tenant-claim");
request.Headers.Add("X-Test-App-Id", "app-claim");
request.Headers.Add("X-Test-Namespace", "ns-claim");
}

private static async Task<string?> ReadCodeAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
return body.TryGetProperty("code", out var code) ? code.GetString() : null;
}

[Fact]
public async Task BindingEndpoints_ShouldMapServiceConnectorAndSecretBindings()
{
Expand Down
Loading