diff --git a/src/platform/Aevatar.GAgentService.Governance.Hosting/Endpoints/ServiceBindingEndpoints.cs b/src/platform/Aevatar.GAgentService.Governance.Hosting/Endpoints/ServiceBindingEndpoints.cs index 47b46e7fa..35ab1b34f 100644 --- a/src/platform/Aevatar.GAgentService.Governance.Hosting/Endpoints/ServiceBindingEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Governance.Hosting/Endpoints/ServiceBindingEndpoints.cs @@ -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 HandleCreateAsync( HttpContext http, string serviceId, @@ -29,22 +36,26 @@ private static async Task 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), @@ -61,22 +72,26 @@ private static async Task 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), @@ -93,8 +108,13 @@ private static async Task HandleRetireAsync( [FromServices] IServiceGovernanceCommandPort commandPort, CancellationToken ct) { + var authenticatedContext = identityResolver.Resolve(); + if (TryValidateOwnerIdentity(request.TenantId, request.AppId, request.Namespace, authenticatedContext) is { } ownerInvalid) + return ownerInvalid; + if (!ServiceIdentityEndpointAccess.TryResolveIdentity( identityResolver, + authenticatedContext, request.TenantId, request.AppId, request.Namespace, @@ -121,8 +141,13 @@ private static async Task 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, @@ -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, diff --git a/src/platform/Aevatar.GAgentService.Governance.Hosting/Identity/ServiceIdentityEndpointAccess.cs b/src/platform/Aevatar.GAgentService.Governance.Hosting/Identity/ServiceIdentityEndpointAccess.cs index 359f84e4a..691c64ae0 100644 --- a/src/platform/Aevatar.GAgentService.Governance.Hosting/Identity/ServiceIdentityEndpointAccess.cs +++ b/src/platform/Aevatar.GAgentService.Governance.Hosting/Identity/ServiceIdentityEndpointAccess.cs @@ -93,15 +93,25 @@ private static bool TryGetSingleClaimValue( public static class ServiceIdentityEndpointAccess { + /// + /// Resolves the owner identity context. When is + /// supplied (the caller already invoked resolver.Resolve()), claim resolution is + /// reused — avoiding the double-Resolve cost when the handler also needs the authenticated + /// context for validation (e.g., TryValidateOwnerIdentity). Pass null to fall + /// through to the original behaviour: when authenticated but claims are missing/ambiguous, + /// returns 403 SERVICE_IDENTITY_ACCESS_DENIED; when unauthenticated, falls back to + /// the request's tenant/app/namespace fields. + /// 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; @@ -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, @@ -141,6 +168,7 @@ public static bool TryResolveIdentity( { if (!TryResolveContext( resolver, + authenticatedContext, fallbackTenantId, fallbackAppId, fallbackNamespace, @@ -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); } diff --git a/test/Aevatar.GAgentService.Integration.Tests/GovernanceEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/GovernanceEndpointsTests.cs index 5f0ec52ca..291194564 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/GovernanceEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/GovernanceEndpointsTests.cs @@ -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; @@ -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", @@ -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(); + } + [Fact] public async Task BindingEndpoints_WhenAuthenticatedBoundServiceIdentityConflictsWithClaims_ShouldReturnBadRequest() { @@ -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 ReadCodeAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadFromJsonAsync(); + return body.TryGetProperty("code", out var code) ? code.GetString() : null; + } + [Fact] public async Task BindingEndpoints_ShouldMapServiceConnectorAndSecretBindings() {