From 2ca49941954c0b4f1238b25db2a2cdcc1f498f7a Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 8 Oct 2026 17:50:58 +0800 Subject: [PATCH 1/9] Add minimal workflow external trigger APIs. Co-Authored-By: Claude Opus 4.6 --- .../ScopeWorkflowScheduleEndpoints.cs | 318 ++++++++++++++++++ .../ScopeWorkflowEndpointsTests.cs | 189 ++++++++++- 2 files changed, 505 insertions(+), 2 deletions(-) diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index d4cbed7cd6..08f44640d4 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Json.Serialization; using Aevatar.AI.Abstractions; using Aevatar.Capabilities; @@ -23,6 +25,23 @@ internal static class ScopeWorkflowScheduleEndpoints public static RouteGroupBuilder MapScopeWorkflowScheduleEndpoints(this RouteGroupBuilder group) { + group.MapPost("/{scopeId}/workflows/{workflowId}/external-trigger", UpsertExternalTrigger) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + group.MapGet("/{scopeId}/workflows/{workflowId}/external-trigger", GetExternalTrigger) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapPost("/{scopeId}/workflow-triggers/{triggerId}:fire", FireExternalTrigger) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); group.MapGet("/{scopeId}/workflows/{workflowId}/schedules", List) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) @@ -80,6 +99,126 @@ public static RouteGroupBuilder MapScopeWorkflowScheduleEndpoints(this RouteGrou return group; } + internal static async Task UpsertExternalTrigger( + HttpContext http, + string scopeId, + string workflowId, + WorkflowExternalTriggerConfigurationHttpRequest input, + [FromServices] IScopeWorkflowQueryPort workflowQueryPort, + [FromServices] IScheduledDispatchApplicationService schedules, + CancellationToken ct = default) + { + var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); + if (resolved.Result != null) + return resolved.Result; + + ScheduledDispatchConfiguration configuration; + ScheduledDispatchMutationContext context; + try + { + context = ResolveMutationContext(http, resolved.Workflow!); + configuration = BuildExternalTriggerConfiguration( + resolved.Workflow!, + input, + BuildExternalTriggerId(scopeId, workflowId), + context.AuthenticatedNyxIdOwnerSubject); + } + catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleConfigurationError(ex, out var result)) + { + return result; + } + + try + { + var receipt = await schedules.EnsureAsync(configuration, context, ct); + var response = WorkflowExternalTriggerHttpResult.FromMutation( + configured: true, + resolved.Workflow!, + configuration, + receipt, + ScheduledDispatchCredentialSourceKind.ScopeOwnerNyxId, + CredentialExpiresAt: null, + PermissionDigest: string.Empty, + PolicyVersion: string.Empty); + return Results.Accepted(BuildExternalTriggerLocation(scopeId, receipt.ScheduleId), response); + } + catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out var result)) + { + return result; + } + } + + internal static async Task GetExternalTrigger( + HttpContext http, + string scopeId, + string workflowId, + [FromServices] IScopeWorkflowQueryPort workflowQueryPort, + [FromServices] IScheduledDispatchApplicationService schedules, + CancellationToken ct = default) + { + var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); + if (resolved.Result != null) + return resolved.Result; + + var triggerId = BuildExternalTriggerId(scopeId, workflowId); + var detail = await schedules.GetAsync(triggerId, ct); + if (detail == null || !BelongsToWorkflow(detail.Schedule, resolved.Workflow!)) + { + return Results.Ok(WorkflowExternalTriggerHttpResult.NotConfigured( + resolved.Workflow!, + triggerId)); + } + + return Results.Ok(WorkflowExternalTriggerHttpResult.FromSummary( + configured: true, + resolved.Workflow!, + detail.Schedule)); + } + + internal static async Task FireExternalTrigger( + HttpContext http, + string scopeId, + string triggerId, + [FromServices] IScheduledDispatchApplicationService schedules, + CancellationToken ct = default) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + if (TryCreateInvalidScheduleIdResult(triggerId, out var invalidTriggerId)) + return invalidTriggerId; + + var detail = await schedules.GetAsync(triggerId, ct); + if (detail == null || !IsWorkflowExternalTriggerForScope(detail.Schedule, scopeId, triggerId)) + { + return Results.NotFound(new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_NOT_FOUND", + message = $"Workflow trigger '{triggerId}' was not found for scope '{scopeId}'.", + }); + } + + try + { + var receipt = await schedules.RunNowAsync( + triggerId, + new ScheduledDispatchMutationContext( + scopeId, + ExpectedServiceTarget: new ScheduledDispatchExpectedServiceTarget( + ScheduledDispatchScheduleKind.Workflow, + ScheduledDispatchTargetKind.ServiceInvocation, + detail.Schedule.ServiceIdentity, + ChatEndpointId)), + ct); + return Results.Accepted( + BuildExternalTriggerLocation(scopeId, triggerId), + WorkflowExternalTriggerFireHttpResult.FromReceipt(receipt)); + } + catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out var result)) + { + return result; + } + } + internal static async Task Create( HttpContext http, string scopeId, @@ -481,6 +620,27 @@ private static bool BelongsToWorkflow(ScheduledDispatchSummary schedule, ScopeWo string.Equals(schedule.ServiceKey, workflow.ServiceKey, StringComparison.Ordinal); } + private static ScheduledDispatchConfiguration BuildExternalTriggerConfiguration( + ScopeWorkflowSummary workflow, + WorkflowExternalTriggerConfigurationHttpRequest input, + string triggerId, + ScheduledServiceInvocationNyxIdSubjectRef? authenticatedOwnerSubject) => + BuildConfiguration( + workflow, + new WorkflowScheduleConfigurationHttpRequest + { + ScheduleId = triggerId, + DisplayName = input.DisplayName, + CronExpression = input.CronExpression, + Timezone = input.Timezone, + Enabled = input.Enabled, + Prompt = input.Prompt, + ScheduleMode = input.ScheduleMode, + OneShotFireAt = input.OneShotFireAt, + }, + triggerId, + authenticatedOwnerSubject); + private static ScheduledDispatchConfiguration BuildConfiguration( ScopeWorkflowSummary workflow, WorkflowScheduleConfigurationHttpRequest input, @@ -634,6 +794,29 @@ private static bool TryCreateInvalidScheduleIdResult(string? scheduleId, out IRe return null; } + private static string BuildExternalTriggerId(string scopeId, string workflowId) + { + var normalizedScopeId = scopeId.Trim(); + var normalizedWorkflowId = workflowId.Trim(); + var workflowHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(normalizedWorkflowId))) + .ToLowerInvariant()[..24]; + return $"workflow-trigger-{normalizedScopeId}-{workflowHash}"; + } + + private static string BuildExternalTriggerLocation(string scopeId, string triggerId) => + $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflow-triggers/{Uri.EscapeDataString(triggerId)}:fire"; + + private static bool IsWorkflowExternalTriggerForScope( + ScheduledDispatchSummary schedule, + string scopeId, + string triggerId) => + schedule.ScheduleId == triggerId && + triggerId.StartsWith($"workflow-trigger-{scopeId}-", StringComparison.Ordinal) && + schedule.ScheduleKind == ScheduledDispatchScheduleKind.Workflow && + schedule.TargetKind == ScheduledDispatchTargetKind.ServiceInvocation && + string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal); + private static string BuildWorkflowScheduleLocation(string scopeId, string workflowId, string scheduleId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflows/{Uri.EscapeDataString(workflowId)}/schedules/{Uri.EscapeDataString(scheduleId)}"; @@ -646,6 +829,141 @@ private sealed record WorkflowScheduleOwnershipResult( IResult? Result); } +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record WorkflowExternalTriggerConfigurationHttpRequest +{ + public string? DisplayName { get; init; } + public string? CronExpression { get; init; } + public string? Timezone { get; init; } + public bool Enabled { get; init; } = true; + public string? Prompt { get; init; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public ScheduledDispatchScheduleMode ScheduleMode { get; init; } = ScheduledDispatchScheduleMode.RecurringCron; + public DateTimeOffset? OneShotFireAt { get; init; } +} + +public sealed record WorkflowExternalTriggerHttpResult +{ + public bool Configured { get; init; } + public required string TriggerId { get; init; } + public required string WorkflowId { get; init; } + public required string ServiceId { get; init; } + public string RevisionId { get; init; } = string.Empty; + public string EndpointId { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public bool Enabled { get; init; } + public string CronExpression { get; init; } = string.Empty; + public string Timezone { get; init; } = string.Empty; + [JsonConverter(typeof(JsonStringEnumConverter))] + public ScheduledDispatchScheduleMode ScheduleMode { get; init; } + public DateTimeOffset? OneShotFireAt { get; init; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public ScheduledDispatchCredentialSourceKind CredentialSourceKind { get; init; } + public bool AgentKeyReady { get; init; } + public DateTimeOffset? CredentialExpiresAt { get; init; } + public string PermissionDigest { get; init; } = string.Empty; + public string PolicyVersion { get; init; } = string.Empty; + + public static WorkflowExternalTriggerHttpResult FromMutation( + bool configured, + ScopeWorkflowSummary workflow, + ScheduledDispatchConfiguration configuration, + ScheduledDispatchMutationReceipt receipt, + ScheduledDispatchCredentialSourceKind credentialSourceKind, + DateTimeOffset? CredentialExpiresAt, + string PermissionDigest, + string PolicyVersion) + { + var invocation = configuration.Target.ServiceInvocation!; + return new WorkflowExternalTriggerHttpResult + { + Configured = configured, + TriggerId = receipt.ScheduleId, + WorkflowId = workflow.WorkflowId, + ServiceId = invocation.Identity.ServiceId, + RevisionId = invocation.RevisionId ?? workflow.ActiveRevisionId, + EndpointId = invocation.EndpointId, + Status = configuration.Enabled ? "enabled" : "disabled", + Enabled = configuration.Enabled, + CronExpression = configuration.CronExpression, + Timezone = configuration.Timezone, + ScheduleMode = configuration.ScheduleMode, + OneShotFireAt = configuration.OneShotFireAt, + CredentialSourceKind = credentialSourceKind, + AgentKeyReady = credentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + CredentialExpiresAt = CredentialExpiresAt, + PermissionDigest = PermissionDigest, + PolicyVersion = PolicyVersion, + }; + } + + public static WorkflowExternalTriggerHttpResult FromSummary( + bool configured, + ScopeWorkflowSummary workflow, + ScheduledDispatchSummary schedule) => + new() + { + Configured = configured, + TriggerId = schedule.ScheduleId, + WorkflowId = workflow.WorkflowId, + ServiceId = schedule.ServiceId, + RevisionId = schedule.ServiceRevisionId, + EndpointId = schedule.ServiceEndpointId, + Status = schedule.Deleted ? "deleted" : schedule.Enabled ? "enabled" : "disabled", + Enabled = schedule.Enabled, + CronExpression = schedule.CronExpression, + Timezone = schedule.Timezone, + ScheduleMode = schedule.ScheduleMode, + OneShotFireAt = schedule.OneShotFireAt, + CredentialSourceKind = schedule.CredentialSourceKind, + AgentKeyReady = schedule.CredentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + CredentialExpiresAt = schedule.CredentialExpiresAt, + PermissionDigest = schedule.PermissionDigest, + PolicyVersion = schedule.PolicyVersion, + }; + + public static WorkflowExternalTriggerHttpResult NotConfigured( + ScopeWorkflowSummary workflow, + string triggerId) => + new() + { + Configured = false, + TriggerId = triggerId, + WorkflowId = workflow.WorkflowId, + ServiceId = workflow.PublishedServiceId, + RevisionId = workflow.ActiveRevisionId, + EndpointId = "chat", + Status = "not_configured", + CredentialSourceKind = ScheduledDispatchCredentialSourceKind.None, + }; +} + +public sealed record WorkflowExternalTriggerFireHttpResult +{ + public required string TriggerId { get; init; } + public bool Accepted { get; init; } + public DateTimeOffset ScheduledFireAt { get; init; } + public required string IdempotencyKey { get; init; } + public required string CommandId { get; init; } + public required string CorrelationId { get; init; } + public DateTimeOffset AckedAt { get; init; } + public string AckStage { get; init; } = string.Empty; + + public static WorkflowExternalTriggerFireHttpResult FromReceipt( + ScheduledDispatchRunNowReceipt receipt) => + new() + { + TriggerId = receipt.ScheduleId, + Accepted = receipt.Accepted, + ScheduledFireAt = receipt.ScheduledFireAt, + IdempotencyKey = receipt.IdempotencyKey, + CommandId = receipt.CommandId, + CorrelationId = receipt.CorrelationId, + AckedAt = receipt.AckedAt, + AckStage = receipt.AckStage, + }; +} + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] public sealed record WorkflowScheduleConfigurationHttpRequest { diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index b490adfc80..b52bf41fc1 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using Aevatar.AI.Abstractions; using Aevatar.CQRS.Core.Abstractions.Interactions; @@ -1667,6 +1668,180 @@ public async Task HandleUpsertWorkflowAsync_ShouldReturnAccepted_WithLocation_Wh body.Should().NotContain("\"workflow\""); } + [Fact] + public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServerOwnedCredential() + { + var http = CreateHttpContext("scope-alpha"); + var workflowQueryPort = new RecordingScopeWorkflowQueryPort + { + LookupResult = RunnableWorkflow(), + }; + var schedules = new RecordingWorkflowScheduledDispatchService(); + var input = new WorkflowExternalTriggerConfigurationHttpRequest + { + DisplayName = "External trigger", + CronExpression = "0 9 * * *", + Timezone = "UTC", + Prompt = "run workflow", + }; + + var result = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( + http, + "scope-alpha", + "wf-alpha", + input, + workflowQueryPort, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + + http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); + schedules.Ensured.Should().ContainSingle(); + schedules.EnsureContexts.Should().ContainSingle().Which!.AuthenticatedNyxIdOwnerSubject + .Should().NotBeNull(); + var configuration = schedules.Ensured[0]; + configuration.ScheduleId.Should().StartWith("workflow-trigger-scope-alpha-"); + configuration.Target.ServiceInvocation!.Auth!.Source + .Should().BeOfType(); + configuration.Target.ServiceInvocation.Payload.Unpack().Prompt + .Should().Be("run workflow"); + + var secondHttp = CreateHttpContext("scope-alpha"); + var secondSchedules = new RecordingWorkflowScheduledDispatchService(); + var secondResult = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( + secondHttp, + "scope-alpha", + "wf-alpha", + input, + workflowQueryPort, + secondSchedules, + CancellationToken.None); + + await secondResult.ExecuteAsync(secondHttp); + + secondSchedules.Ensured.Should().ContainSingle(); + secondSchedules.Ensured[0].ScheduleId.Should().Be(configuration.ScheduleId); + } + + [Fact] + public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindingIsAbsent() + { + var http = CreateHttpContext("scope-alpha"); + var workflowQueryPort = new RecordingScopeWorkflowQueryPort + { + LookupResult = RunnableWorkflow(), + }; + var schedules = new RecordingWorkflowScheduledDispatchService(); + + var result = await ScopeWorkflowScheduleEndpoints.GetExternalTrigger( + http, + "scope-alpha", + "wf-alpha", + workflowQueryPort, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + var body = await ReadBodyAsync(http.Response); + + http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + body.Should().Contain("\"configured\":false"); + body.Should().Contain("\"status\":\"not_configured\""); + schedules.LastScheduleGet.Should().StartWith("workflow-trigger-scope-alpha-"); + } + + [Fact] + public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredBinding() + { + var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); + var http = CreateHttpContext("scope-alpha"); + var workflowQueryPort = new RecordingScopeWorkflowQueryPort + { + LookupResult = RunnableWorkflow(), + }; + var schedules = new RecordingWorkflowScheduledDispatchService + { + Detail = new ScheduledDispatchDetail( + WorkflowScheduleSummary(triggerId) with + { + ServiceRevisionId = "rev-alpha", + CredentialSourceKind = ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + PermissionDigest = "digest-alpha", + PolicyVersion = "policy-alpha", + }, + []), + }; + + var result = await ScopeWorkflowScheduleEndpoints.GetExternalTrigger( + http, + "scope-alpha", + "wf-alpha", + workflowQueryPort, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + var body = await ReadBodyAsync(http.Response); + + http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + body.Should().Contain("\"configured\":true"); + body.Should().Contain($"\"triggerId\":\"{triggerId}\""); + body.Should().Contain("\"credentialSourceKind\":\"scheduledInvocationAgentKey\""); + body.Should().Contain("\"agentKeyReady\":true"); + body.Should().Contain("\"permissionDigest\":\"digest-alpha\""); + } + + [Fact] + public async Task WorkflowExternalTriggerFire_ShouldRejectMismatchedScopeWithoutMutation() + { + var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); + var http = CreateHttpContext("scope-beta"); + var schedules = new RecordingWorkflowScheduledDispatchService + { + Detail = new ScheduledDispatchDetail(WorkflowScheduleSummary(triggerId), []), + }; + + var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( + http, + "scope-beta", + triggerId, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + var body = await ReadBodyAsync(http.Response); + + http.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + body.Should().Contain("WORKFLOW_EXTERNAL_TRIGGER_NOT_FOUND"); + schedules.RunNowScheduleIds.Should().BeEmpty(); + } + + [Fact] + public async Task WorkflowExternalTriggerFire_ShouldPassExpectedWorkflowTarget() + { + var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); + var http = CreateHttpContext("scope-alpha"); + var schedules = new RecordingWorkflowScheduledDispatchService + { + Detail = new ScheduledDispatchDetail(WorkflowScheduleSummary(triggerId), []), + }; + + var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( + http, + "scope-alpha", + triggerId, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + + http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); + schedules.RunNowScheduleIds.Should().ContainSingle().Which.Should().Be(triggerId); + schedules.RunNowContexts.Should().ContainSingle().Which!.ExpectedServiceTarget!.ServiceIdentity.ServiceId + .Should().Be("svc-alpha"); + } + [Fact] public async Task WorkflowScheduleCreate_ShouldResolvePublishedServiceTargetWithoutTeamOwner() { @@ -2453,6 +2628,10 @@ private static WorkflowRunEventEnvelope BuildRawObservedWorkflowExecutionStarted }, string.Empty); + private static string ExternalTriggerId(string scopeId, string workflowId) => + $"workflow-trigger-{scopeId}-{Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(workflowId))).ToLowerInvariant()[..24]}"; + private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleId) => new( scheduleId, "Daily run", @@ -2542,6 +2721,8 @@ private sealed class RecordingWorkflowScheduledDispatchService : IScheduledDispa { public List Created { get; } = []; public List CreateContexts { get; } = []; + public List Ensured { get; } = []; + public List EnsureContexts { get; } = []; public List<(string ScheduleId, ScheduledDispatchConfiguration Configuration)> Updated { get; } = []; public List UpdateContexts { get; } = []; public List EnableContexts { get; } = []; @@ -2570,8 +2751,12 @@ public Task CreateAsync( public Task EnsureAsync( ScheduledDispatchConfiguration configuration, ScheduledDispatchMutationContext? context = null, - CancellationToken ct = default) => - Task.FromResult(MutationReceipt(configuration.ScheduleId)); + CancellationToken ct = default) + { + Ensured.Add(configuration); + EnsureContexts.Add(context); + return Task.FromResult(MutationReceipt(configuration.ScheduleId)); + } public Task UpdateAsync( string scheduleId, From f9ddb90b449299d784b8a83b4a224d6d81cf9f3e Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 8 Oct 2026 18:48:05 +0800 Subject: [PATCH 2/9] Harden workflow external trigger ownership. Co-Authored-By: Claude Opus 4.6 --- .../ScopeWorkflowScheduleEndpoints.cs | 39 +++++---- .../ScopeWorkflowEndpointsTests.cs | 80 ++++++++++++------- 2 files changed, 73 insertions(+), 46 deletions(-) diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index 08f44640d4..bb40868e29 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -120,7 +120,7 @@ internal static async Task UpsertExternalTrigger( configuration = BuildExternalTriggerConfiguration( resolved.Workflow!, input, - BuildExternalTriggerId(scopeId, workflowId), + BuildExternalTriggerId(resolved.Workflow!), context.AuthenticatedNyxIdOwnerSubject); } catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleConfigurationError(ex, out var result)) @@ -132,7 +132,6 @@ internal static async Task UpsertExternalTrigger( { var receipt = await schedules.EnsureAsync(configuration, context, ct); var response = WorkflowExternalTriggerHttpResult.FromMutation( - configured: true, resolved.Workflow!, configuration, receipt, @@ -160,7 +159,7 @@ internal static async Task GetExternalTrigger( if (resolved.Result != null) return resolved.Result; - var triggerId = BuildExternalTriggerId(scopeId, workflowId); + var triggerId = BuildExternalTriggerId(resolved.Workflow!); var detail = await schedules.GetAsync(triggerId, ct); if (detail == null || !BelongsToWorkflow(detail.Schedule, resolved.Workflow!)) { @@ -611,6 +610,7 @@ private static bool BelongsToWorkflow(ScheduledDispatchSummary schedule, ScopeWo if (schedule.ScheduleKind != ScheduledDispatchScheduleKind.Workflow || schedule.TargetKind != ScheduledDispatchTargetKind.ServiceInvocation || !string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal) || + !string.Equals(schedule.ServiceIdentity.TenantId, workflow.ScopeId, StringComparison.Ordinal) || !string.Equals(schedule.ServiceId, workflow.PublishedServiceId, StringComparison.Ordinal)) { return false; @@ -794,14 +794,16 @@ private static bool TryCreateInvalidScheduleIdResult(string? scheduleId, out IRe return null; } - private static string BuildExternalTriggerId(string scopeId, string workflowId) + private static string BuildExternalTriggerId(ScopeWorkflowSummary workflow) { - var normalizedScopeId = scopeId.Trim(); - var normalizedWorkflowId = workflowId.Trim(); - var workflowHash = Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(normalizedWorkflowId))) - .ToLowerInvariant()[..24]; - return $"workflow-trigger-{normalizedScopeId}-{workflowHash}"; + var triggerKey = string.Join( + ":", + workflow.ScopeId.Trim(), + workflow.DefinitionActorId.Trim(), + workflow.PublishedServiceId.Trim()); + var triggerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(triggerKey))) + .ToLowerInvariant()[..32]; + return $"workflow-trigger-{triggerHash}"; } private static string BuildExternalTriggerLocation(string scopeId, string triggerId) => @@ -812,10 +814,10 @@ private static bool IsWorkflowExternalTriggerForScope( string scopeId, string triggerId) => schedule.ScheduleId == triggerId && - triggerId.StartsWith($"workflow-trigger-{scopeId}-", StringComparison.Ordinal) && schedule.ScheduleKind == ScheduledDispatchScheduleKind.Workflow && schedule.TargetKind == ScheduledDispatchTargetKind.ServiceInvocation && - string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal); + string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal) && + string.Equals(schedule.ServiceIdentity.TenantId, scopeId, StringComparison.Ordinal); private static string BuildWorkflowScheduleLocation(string scopeId, string workflowId, string scheduleId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflows/{Uri.EscapeDataString(workflowId)}/schedules/{Uri.EscapeDataString(scheduleId)}"; @@ -845,6 +847,10 @@ public sealed record WorkflowExternalTriggerConfigurationHttpRequest public sealed record WorkflowExternalTriggerHttpResult { public bool Configured { get; init; } + public string AcceptanceStage { get; init; } = string.Empty; + public string CommandId { get; init; } = string.Empty; + public string CorrelationId { get; init; } = string.Empty; + public DateTimeOffset? AcceptedAt { get; init; } public required string TriggerId { get; init; } public required string WorkflowId { get; init; } public required string ServiceId { get; init; } @@ -865,7 +871,6 @@ public sealed record WorkflowExternalTriggerHttpResult public string PolicyVersion { get; init; } = string.Empty; public static WorkflowExternalTriggerHttpResult FromMutation( - bool configured, ScopeWorkflowSummary workflow, ScheduledDispatchConfiguration configuration, ScheduledDispatchMutationReceipt receipt, @@ -877,13 +882,17 @@ public static WorkflowExternalTriggerHttpResult FromMutation( var invocation = configuration.Target.ServiceInvocation!; return new WorkflowExternalTriggerHttpResult { - Configured = configured, + Configured = false, + AcceptanceStage = receipt.AckStage, + CommandId = receipt.CommandId, + CorrelationId = receipt.CorrelationId, + AcceptedAt = receipt.AckedAt, TriggerId = receipt.ScheduleId, WorkflowId = workflow.WorkflowId, ServiceId = invocation.Identity.ServiceId, RevisionId = invocation.RevisionId ?? workflow.ActiveRevisionId, EndpointId = invocation.EndpointId, - Status = configuration.Enabled ? "enabled" : "disabled", + Status = "pending", Enabled = configuration.Enabled, CronExpression = configuration.CronExpression, Timezone = configuration.Timezone, diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index b52bf41fc1..e79387d027 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -1695,8 +1695,12 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe CancellationToken.None); await result.ExecuteAsync(http); + var body = await ReadBodyAsync(http.Response); http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); + body.Should().Contain("\"configured\":false"); + body.Should().Contain("\"status\":\"pending\""); + body.Should().Contain("\"acceptanceStage\":\"accepted\""); schedules.Ensured.Should().ContainSingle(); schedules.EnsureContexts.Should().ContainSingle().Which!.AuthenticatedNyxIdOwnerSubject .Should().NotBeNull(); @@ -2628,37 +2632,51 @@ private static WorkflowRunEventEnvelope BuildRawObservedWorkflowExecutionStarted }, string.Empty); - private static string ExternalTriggerId(string scopeId, string workflowId) => - $"workflow-trigger-{scopeId}-{Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(workflowId))).ToLowerInvariant()[..24]}"; - - private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleId) => new( - scheduleId, - "Daily run", - ScheduledDispatchTargetKind.ServiceInvocation, - "target-actor-alpha", - Any.Pack(new ChatRequestEvent()).TypeUrl, - "svc-key-alpha", - "svc-alpha", - "chat", - "0 9 * * *", - "UTC", - true, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow, - null, - null, - string.Empty, - string.Empty, - string.Empty, - string.Empty, - string.Empty, - 0, - 0, - new Dictionary(StringComparer.Ordinal), - $"actor:{scheduleId}", - "run workflow", - ScheduledDispatchScheduleKind.Workflow); + private static string ExternalTriggerId(string scopeId, string workflowId) + { + var triggerKey = string.Join(":", scopeId, "definition-actor-alpha", "svc-alpha"); + return $"workflow-trigger-{Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(triggerKey))).ToLowerInvariant()[..32]}"; + } + + private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleId) => + new( + scheduleId, + "Daily run", + ScheduledDispatchTargetKind.ServiceInvocation, + "target-actor-alpha", + Any.Pack(new ChatRequestEvent()).TypeUrl, + "svc-key-alpha", + "svc-alpha", + "chat", + "0 9 * * *", + "UTC", + true, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + null, + null, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + 0, + 0, + new Dictionary(StringComparer.Ordinal), + $"actor:{scheduleId}", + "run workflow", + ScheduledDispatchScheduleKind.Workflow) + { + ServiceIdentity = new ServiceIdentity + { + TenantId = "scope-alpha", + AppId = "workflow-app", + Namespace = "workflow-ns", + ServiceId = "svc-alpha", + }, + ServiceRevisionId = "rev-alpha", + }; private sealed class RecordingScopeWorkflowQueryPort : IScopeWorkflowQueryPort, From 3c11949ff557e43709b977dfbb4e9f70059d3b1b Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 8 Oct 2026 18:52:42 +0800 Subject: [PATCH 3/9] Reject foreign workflow trigger collisions. Co-Authored-By: Claude Opus 4.6 --- .../ScopeWorkflowScheduleEndpoints.cs | 19 ++++++++ .../ScopeWorkflowEndpointsTests.cs | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index bb40868e29..967c7cae87 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -128,6 +128,25 @@ internal static async Task UpsertExternalTrigger( return result; } + var existing = await schedules.GetAsync(configuration.ScheduleId, ct); + if (existing != null && !BelongsToWorkflow(existing.Schedule, resolved.Workflow!)) + { + return Results.Conflict(new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_ID_CONFLICT", + message = $"Workflow trigger '{configuration.ScheduleId}' is already owned by another service target.", + }); + } + + if (existing?.Schedule.Deleted == true) + { + return Results.Conflict(new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_TOMBSTONED", + message = $"Workflow trigger '{configuration.ScheduleId}' is permanently deleted and cannot be reused.", + }); + } + try { var receipt = await schedules.EnsureAsync(configuration, context, ct); diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index e79387d027..3323dcf0d6 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -1728,6 +1728,49 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe secondSchedules.Ensured[0].ScheduleId.Should().Be(configuration.ScheduleId); } + [Fact] + public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithoutMutation() + { + var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); + var http = CreateHttpContext("scope-alpha"); + var workflowQueryPort = new RecordingScopeWorkflowQueryPort + { + LookupResult = RunnableWorkflow(), + }; + var schedules = new RecordingWorkflowScheduledDispatchService + { + Detail = new ScheduledDispatchDetail( + WorkflowScheduleSummary(triggerId) with + { + ServiceIdentity = new ServiceIdentity + { + TenantId = "scope-other", + AppId = "workflow-app", + Namespace = "workflow-ns", + ServiceId = "svc-other", + }, + ServiceId = "svc-other", + }, + []), + }; + + var result = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( + http, + "scope-alpha", + "wf-alpha", + new WorkflowExternalTriggerConfigurationHttpRequest { Prompt = "run workflow" }, + workflowQueryPort, + schedules, + CancellationToken.None); + + await result.ExecuteAsync(http); + var body = await ReadBodyAsync(http.Response); + + http.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + body.Should().Contain("WORKFLOW_EXTERNAL_TRIGGER_ID_CONFLICT"); + schedules.Ensured.Should().BeEmpty(); + } + [Fact] public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindingIsAbsent() { From 770c00dfcb2f075fc99d261bfc3ea91ee625ec6e Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 8 Oct 2026 21:52:20 +0800 Subject: [PATCH 4/9] Provision workflow external trigger Agent Keys. Co-Authored-By: Claude Opus 4.6 --- .../ServiceCollectionExtensions.cs | 2 + ...kflowExternalTriggerProvisioningService.cs | 850 ++++++++++++++++++ .../ScopeWorkflowScheduleEndpoints.cs | 62 +- .../ScopeWorkflowEndpointsTests.cs | 86 +- 4 files changed, 982 insertions(+), 18 deletions(-) create mode 100644 src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs diff --git a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs index 742645f28e..07452df00d 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs @@ -209,6 +209,7 @@ public static IServiceCollection AddGAgentServiceCapability( services.AddScheduledCredentialAdmissionPort(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddTransient(); services.TryAddSingleton, StaticGAgentStreamInvocationApplicationService>(); @@ -293,6 +294,7 @@ public static IServiceCollection AddScheduledDispatchCapability( services.AddScheduledCredentialAdmissionPort(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddTransient(); return services; diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs new file mode 100644 index 0000000000..4faa4afc66 --- /dev/null +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs @@ -0,0 +1,850 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Aevatar.AI.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Credentials; +using Aevatar.GAgentService.Abstractions; +using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.GAgentService.Abstractions.Schedules; +using Aevatar.GAgentService.Abstractions.Schedules.Authorization; +using Aevatar.Studio.Application.Provisioning; +using Aevatar.Workflow.Abstractions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.GAgentService.Hosting.Endpoints.Schedules; + +internal interface IWorkflowExternalTriggerProvisioningPort +{ + Task ProvisionAsync( + ScopeWorkflowSummary workflow, + ScheduledDispatchConfiguration configuration, + ScheduledDispatchMutationContext context, + StudioMemberAutomationHttpAuthority authority, + CancellationToken ct = default); +} + +internal sealed record WorkflowExternalTriggerProvisioningResult( + ScheduledDispatchMutationReceipt Receipt, + ScheduledDispatchConfiguration Configuration, + ScheduledDispatchCredentialSourceKind CredentialSourceKind, + DateTimeOffset? CredentialExpiresAt, + string PermissionDigest, + string PolicyVersion); + +internal sealed class WorkflowExternalTriggerProvisioningService : IWorkflowExternalTriggerProvisioningPort +{ + private const string WorkflowInvokeEndpointId = "chat"; + private const string ProvisioningBearerCapabilityScope = "proxy"; + private const string ExternalTriggerTeamId = "workflow-external-trigger"; + + private readonly IScheduledDispatchApplicationService _scheduleService; + private readonly IScheduledInvocationAuthorizationPlanner _authorizationPlanner; + private readonly IScheduledInvocationAuthorizationRevalidator _authorizationRevalidator; + private readonly IScheduledInvocationWorkflowEvidenceQueryPort _workflowEvidenceQueryPort; + private readonly IStudioScheduledCredentialMaterializer _credentialMaterializer; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public WorkflowExternalTriggerProvisioningService( + IScheduledDispatchApplicationService scheduleService, + IScheduledInvocationAuthorizationPlanner authorizationPlanner, + IScheduledInvocationAuthorizationRevalidator authorizationRevalidator, + IScheduledInvocationWorkflowEvidenceQueryPort workflowEvidenceQueryPort, + IStudioScheduledCredentialMaterializer credentialMaterializer, + TimeProvider timeProvider, + ILogger? logger = null) + { + _scheduleService = scheduleService ?? throw new ArgumentNullException(nameof(scheduleService)); + _authorizationPlanner = authorizationPlanner ?? throw new ArgumentNullException(nameof(authorizationPlanner)); + _authorizationRevalidator = authorizationRevalidator ?? throw new ArgumentNullException(nameof(authorizationRevalidator)); + _workflowEvidenceQueryPort = workflowEvidenceQueryPort ?? throw new ArgumentNullException(nameof(workflowEvidenceQueryPort)); + _credentialMaterializer = credentialMaterializer ?? throw new ArgumentNullException(nameof(credentialMaterializer)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? NullLogger.Instance; + } + + public async Task ProvisionAsync( + ScopeWorkflowSummary workflow, + ScheduledDispatchConfiguration configuration, + ScheduledDispatchMutationContext context, + StudioMemberAutomationHttpAuthority authority, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(workflow); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(authority); + + var scheduleId = NormalizeRequired(configuration.ScheduleId, nameof(configuration.ScheduleId)); + var serviceInvocation = configuration.Target.ServiceInvocation + ?? throw new ArgumentException("External trigger target must be a service invocation.", nameof(configuration)); + var workflowEvidence = await _workflowEvidenceQueryPort.GetAsync( + workflow.ScopeId, + workflow.PublishedServiceId, + workflow.ActiveRevisionId, + ct); + if (workflowEvidence == null) + throw new InvalidOperationException("workflow_authorization_evidence_not_found"); + + var authorizationRequest = BuildAuthorizationRequest( + workflow, + workflowEvidence, + authority.AuthenticatedOwner); + var firstPlan = await _authorizationPlanner.PlanAsync(authorizationRequest, ct); + if (!firstPlan.Success) + throw new InvalidOperationException(firstPlan.Detail); + + var plan = firstPlan.Plan!; + EnsureRequiredDisclosures(plan); + var validation = await _authorizationRevalidator.RevalidateAsync( + authorizationRequest, + BuildConfirmation(authorizationRequest, plan.PermissionDigest, plan.CredentialPolicy.PolicyVersion), + ct); + if (!validation.Success) + throw new InvalidOperationException(validation.Detail); + + var validatedPlan = validation.ValidatedPlan!; + var authorizationFact = ToScheduleAuthorizationFact(validatedPlan.Plan); + var callerAuthority = BuildScheduleCallerAuthority(authority.AuthenticatedOwner); + var owner = BuildOwner(workflow); + var existing = await _scheduleService.GetAsync(scheduleId, ct); + var operationKind = existing?.Schedule.CredentialSourceKind == + ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey + ? TeamAutomationOperationKind.Reauthorize + : TeamAutomationOperationKind.Create; + var operationId = BuildOperationIdentity( + operationKind, + scheduleId, + plan.PermissionDigest); + var effectLocator = _credentialMaterializer.CreateEffectLocator( + scheduleId, + operationId, + ToAuthorizationOwner(authority.AuthenticatedOwner)); + var activationDecision = BuildActivationDecision( + configuration, + serviceInvocation, + owner, + callerAuthority, + authorizationFact); + var mutationDigest = BuildTeamAutomationMutationDigest(activationDecision); + var idempotencyKey = mutationDigest; + + var began = await _scheduleService.BeginTeamAutomationCredentialOperationAsync( + new TeamAutomationCredentialOperation( + scheduleId, + owner, + operationId, + idempotencyKey, + plan.PermissionDigest, + plan.CredentialPolicy.PolicyVersion, + operationKind, + effectLocator, + activationDecision, + mutationDigest), + ct); + if (!began.Admission.Accepted) + throw new InvalidOperationException("workflow_external_trigger_credential_begin_rejected"); + if (!began.Outcome.OwnsEffectAttempt) + { + return new WorkflowExternalTriggerProvisioningResult( + began.Admission, + configuration, + ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + null, + plan.PermissionDigest, + plan.CredentialPolicy.PolicyVersion); + } + + var effectAttemptId = NormalizeRequired( + began.Outcome.EffectAttemptId, + nameof(began.Outcome.EffectAttemptId)); + var committedEffectLocator = began.Outcome.CredentialEffectLocator + ?? throw new InvalidOperationException("team_automation_credential_effect_locator_missing"); + if (committedEffectLocator != effectLocator) + throw new InvalidOperationException("team_automation_credential_effect_locator_conflict"); + + StudioScheduledCredential? credential = null; + var candidateCommitted = began.Outcome.CandidateCredential != null; + var candidateCommitAttempted = candidateCommitted; + var activationAttempted = false; + try + { + credential = candidateCommitted + ? ToStudioScheduledCredential( + began.Outcome.CandidateCredential!, + began.Outcome.CandidateOwner) + : await _credentialMaterializer.MaterializeAsync( + authority.ProvisioningBearerToken, + validatedPlan, + scheduleId, + operationId, + effectLocator, + began.Outcome.NewOperationCommitted + ? StudioScheduledCredentialMaterializationMode.Initial + : StudioScheduledCredentialMaterializationMode.Recovery, + BuildOwnerScope(authority.AuthenticatedOwner), + ct); + EnsureCredentialMatchesPlan(credential, plan, _timeProvider.GetUtcNow()); + if (!candidateCommitted) + { + candidateCommitAttempted = true; + var candidate = await _scheduleService.RecordTeamAutomationCredentialCandidateAsync( + scheduleId, + owner, + operationId, + idempotencyKey, + effectAttemptId, + BuildScheduleCredential(credential), + credential.Owner, + ct); + if (!candidate.Admission.Accepted) + throw new InvalidOperationException("team_automation_candidate_rejected"); + candidateCommitted = true; + } + + var activatedConfiguration = configuration with + { + Target = configuration.Target with + { + ServiceInvocation = serviceInvocation with + { + Auth = BuildScheduleAuth(credential, callerAuthority), + AuthorizationFact = CloneScheduleAuthorizationFact(authorizationFact), + }, + }, + TeamAutomationOwner = owner, + }; + activationAttempted = true; + var activation = await _scheduleService.CompleteTeamAutomationCredentialOperationAsync( + scheduleId, + owner, + operationId, + idempotencyKey, + effectAttemptId, + BuildScheduleCredential(credential), + activatedConfiguration, + ct); + if (!activation.Admission.Accepted) + throw new InvalidOperationException("team_automation_activation_rejected"); + _ = await ExecutePendingRevocationAsync( + activation.Outcome, + authority.ProvisioningBearerToken, + authority.AuthenticatedOwner, + owner, + CancellationToken.None); + return new WorkflowExternalTriggerProvisioningResult( + activation.Admission, + activatedConfiguration, + ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + credential.ExpiresAtUtc, + plan.PermissionDigest, + plan.CredentialPolicy.PolicyVersion); + } + catch (Exception ex) + { + if (candidateCommitted && !activationAttempted) + { + _ = await TryRecordFailureAsync( + scheduleId, + owner, + operationId, + idempotencyKey, + effectAttemptId, + ToStableFailureCode(ex), + CancellationToken.None); + } + else if (!candidateCommitAttempted && credential != null) + { + try + { + _ = await _credentialMaterializer.RevokeAsync( + authority.ProvisioningBearerToken, + authority.AuthenticatedOwner, + credential, + revokeNyxId: true, + revokeVault: true, + CancellationToken.None); + } + catch (Exception revokeEx) + { + _logger.LogWarning( + revokeEx, + "Failed to revoke external trigger credential after provisioning failure for schedule {ScheduleId}.", + scheduleId); + } + } + + throw; + } + } + + private ScheduledInvocationAuthorizationRequest BuildAuthorizationRequest( + ScopeWorkflowSummary workflow, + ScheduledInvocationWorkflowEvidence evidence, + AuthenticatedAuthorizationOwnerContext authenticatedOwner) + { + var capabilities = ResolveWorkflowCapabilities(evidence.ExternalCapabilities); + var evaluatedAtUtc = _timeProvider.GetUtcNow(); + return new ScheduledInvocationAuthorizationRequest( + new ScheduledInvocationTarget + { + ScheduledAgent = new ScheduledAgentInvocationTarget + { + RegistrationScopeId = workflow.ScopeId, + ExecutionScopeId = workflow.ScopeId, + ScheduledAgentId = workflow.WorkflowId, + }, + }, + authenticatedOwner, + capabilities, + evidence.ServiceGrantRequirement, + evaluatedAtUtc.AddDays(30), + evaluatedAtUtc, + [new AuthorizationSourceStamp + { + SourceKind = AuthorizationSourceKind.WorkflowRevision, + SourceId = workflow.ActiveRevisionId, + StateVersion = evidence.StateVersion, + }]); + } + + private static IReadOnlyList ResolveWorkflowCapabilities( + IEnumerable capabilities) + { + var services = new SortedDictionary(StringComparer.Ordinal); + foreach (var capability in capabilities) + { + NyxIdUserServiceCapabilityRef? service = capability.CapabilityCase switch + { + ExternalWorkflowCapabilityRef.CapabilityOneofCase.NyxIdUserService => + capability.NyxIdUserService.Clone(), + ExternalWorkflowCapabilityRef.CapabilityOneofCase.NyxIdUserRequest => + new NyxIdUserServiceCapabilityRef + { + UserServiceId = capability.NyxIdUserRequest.Request?.UserServiceId ?? string.Empty, + ServiceSlugSnapshot = capability.NyxIdUserRequest.ServiceSlugSnapshot, + }, + ExternalWorkflowCapabilityRef.CapabilityOneofCase.CodeExecution => + new NyxIdUserServiceCapabilityRef + { + UserServiceId = capability.CodeExecution.UserServiceId, + ServiceSlugSnapshot = capability.CodeExecution.ServiceSlugSnapshot, + }, + _ => null, + }; + if (service == null) + continue; + var userServiceId = NormalizeRequired(service.UserServiceId, nameof(service.UserServiceId)); + if (!services.TryGetValue(userServiceId, out var existing)) + { + services[userServiceId] = service; + continue; + } + if (existing.ServiceSlugSnapshot.Length == 0 && service.ServiceSlugSnapshot.Length > 0) + existing.ServiceSlugSnapshot = service.ServiceSlugSnapshot; + } + return services.Values.ToArray(); + } + + private static TeamMemberAutomationOwner BuildOwner(ScopeWorkflowSummary workflow) => + new( + NormalizeRequired(workflow.ScopeId, nameof(workflow.ScopeId)), + NormalizeRequired(workflow.WorkflowId, nameof(workflow.WorkflowId)), + ExternalTriggerTeamId); + + private static ScheduledCallerNyxIdAuthority BuildScheduleCallerAuthority( + AuthenticatedAuthorizationOwnerContext owner) + { + var bindingId = NormalizeRequired(owner.VerifiedBindingId, nameof(owner.VerifiedBindingId)); + return new ScheduledCallerNyxIdAuthority + { + Platform = NormalizeRequired(owner.SubjectPlatform, nameof(owner.SubjectPlatform)), + Tenant = NormalizeOptional(owner.SubjectTenant) ?? string.Empty, + ExternalUserId = NormalizeRequired(owner.SubjectExternalUserId, nameof(owner.SubjectExternalUserId)), + Scope = ProvisioningBearerCapabilityScope, + BindingId = bindingId, + }; + } + + private static TeamAutomationActivationDecision BuildActivationDecision( + ScheduledDispatchConfiguration configuration, + ScheduledServiceInvocationTargetDescriptor serviceInvocation, + TeamMemberAutomationOwner owner, + ScheduledCallerNyxIdAuthority callerAuthority, + ScheduledInvocationAuthorizationFact authorizationFact) => + new( + configuration.ScheduleId, + configuration.DisplayName, + owner, + serviceInvocation.Identity, + serviceInvocation.EndpointId, + serviceInvocation.Payload.Clone(), + callerAuthority.Clone(), + CloneScheduleAuthorizationFact(authorizationFact), + configuration.CronExpression, + configuration.Timezone, + configuration.Enabled, + configuration.ScheduleKind, + configuration.Headers, + configuration.ScheduleMode, + configuration.OneShotFireAt, + configuration.CredentialRequirementTargetKind, + serviceInvocation.RevisionId ?? string.Empty, + serviceInvocation.Caller); + + private static ScheduledInvocationAuthorizationConfirmation BuildConfirmation( + ScheduledInvocationAuthorizationRequest request, + string permissionDigest, + string policyVersion) => + new() + { + InvocationTarget = request.InvocationTarget.Clone(), + Owner = request.Owner.Clone(), + SchemaVersion = ScheduledInvocationAuthorizationContractVersions.Schema, + PolicyVersion = NormalizeRequired(policyVersion, nameof(policyVersion)), + PermissionDigest = NormalizeRequired(permissionDigest, nameof(permissionDigest)), + }; + + private static ScheduledServiceInvocationAuth BuildScheduleAuth( + StudioScheduledCredential credential, + ScheduledCallerNyxIdAuthority callerAuthority) => + new(BuildScheduleCredential(credential)) + { + CallerAuthority = callerAuthority.Clone(), + }; + + private static ScheduledInvocationAgentKeyCredentialReference BuildScheduleCredential( + StudioScheduledCredential credential) => + new( + credential.SecretReference.Clone(), + credential.ApiKeyId, + credential.ExpiresAtUtc.ToUnixTimeMilliseconds(), + credential.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); + + private static StudioScheduledCredential ToStudioScheduledCredential( + ScheduledInvocationAgentKeyCredentialReference credential, + ScheduledInvocationAuthorizationOwner? owner) + { + ArgumentNullException.ThrowIfNull(credential); + return new StudioScheduledCredential( + NormalizeRequired(credential.ApiKeyId, nameof(credential.ApiKeyId)), + credential.SecretReference?.Clone() ?? throw new InvalidOperationException("revocation_descriptor_missing"), + DateTimeOffset.FromUnixTimeMilliseconds(credential.KeyExpiresAtUnixMs), + owner ?? throw new InvalidOperationException("credential_owner_missing"), + credential.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); + } + + private async Task ExecutePendingRevocationAsync( + TeamAutomationOperationCommittedOutcome outcome, + string bearerToken, + AuthenticatedAuthorizationOwnerContext authenticatedOwner, + TeamMemberAutomationOwner owner, + CancellationToken ct) + { + if (!outcome.NyxIdRevocationPending && !outcome.VaultRevocationPending) + return true; + if (!outcome.OwnsEffectAttempt) + return false; + + var result = outcome.PendingRevocationCredential == null || outcome.PendingRevocationOwner == null + ? new StudioScheduledCredentialRevocationResult( + !outcome.NyxIdRevocationPending, + !outcome.VaultRevocationPending, + "revocation_descriptor_missing") + : await RevokePendingCredentialAsync( + bearerToken, + authenticatedOwner, + outcome, + ct); + var completion = await _scheduleService.CompleteTeamAutomationRevocationAsync( + outcome.ScheduleId, + owner, + outcome.OperationId, + outcome.IdempotencyKey, + NormalizeRequired(outcome.EffectAttemptId, nameof(outcome.EffectAttemptId)), + result.NyxIdRevoked, + result.VaultRevoked, + result.ErrorCode, + ct); + return completion.Admission.Accepted && result.NyxIdRevoked && result.VaultRevoked; + } + + private async Task RevokePendingCredentialAsync( + string bearerToken, + AuthenticatedAuthorizationOwnerContext authenticatedOwner, + TeamAutomationOperationCommittedOutcome outcome, + CancellationToken ct) + { + var pending = outcome.PendingRevocationCredential!; + var credential = new StudioScheduledCredential( + pending.ApiKeyId, + pending.SecretReference.Clone(), + DateTimeOffset.FromUnixTimeMilliseconds(pending.KeyExpiresAtUnixMs), + outcome.PendingRevocationOwner!, + pending.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); + try + { + return await _credentialMaterializer.RevokeAsync( + bearerToken, + authenticatedOwner, + credential, + outcome.NyxIdRevocationPending, + outcome.VaultRevocationPending, + ct); + } + catch (UnauthorizedAccessException) + { + return new StudioScheduledCredentialRevocationResult( + !outcome.NyxIdRevocationPending, + !outcome.VaultRevocationPending, + "credential_owner_mismatch"); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + return new StudioScheduledCredentialRevocationResult( + !outcome.NyxIdRevocationPending, + !outcome.VaultRevocationPending, + "credential_revocation_transient"); + } + } + + private async Task TryRecordFailureAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + string operationId, + string idempotencyKey, + string effectAttemptId, + string errorCode, + CancellationToken ct) + { + try + { + var failure = await _scheduleService.FailTeamAutomationCredentialOperationAsync( + scheduleId, + owner, + operationId, + idempotencyKey, + effectAttemptId, + errorCode, + ct); + return failure.Outcome; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to record external trigger credential operation failure for schedule {ScheduleId} and operation {OperationId}.", + scheduleId, + operationId); + return null; + } + } + + private static ScheduledInvocationAuthorizationFact ToScheduleAuthorizationFact( + ScheduledInvocationAuthorizationPlan plan) + { + var policy = plan.CredentialPolicy + ?? throw new InvalidOperationException("scheduled_authorization_policy_missing"); + var catalog = plan.CatalogAuthority; + var disclosure = plan.Disclosures.ToHashSet(); + return new ScheduledInvocationAuthorizationFact( + plan.PermissionDigest, + policy.PolicyVersion, + new ScheduledInvocationAuthorizationOwner( + plan.Owner.Authority, + plan.Owner.OwnerKind.ToString(), + plan.Owner.OwnerSubject), + plan.NyxIdServiceGrants.Select(static grant => + new ScheduledInvocationAuthorizationServiceGrant( + grant.UserServiceId, + grant.NodeIds.ToArray(), + grant.NodeGrantRequirement == AuthorizationGrantRequirement.NotRequired)).ToArray(), + string.Join(' ', policy.Scopes.Select(ToScopeName).Order(StringComparer.Ordinal)), + policy.ExpiresAt.ToDateTimeOffset(), + policy.ServiceGrantRequirement == AuthorizationGrantRequirement.NotRequired, + new ScheduledInvocationAuthorizationDisclosure( + disclosure.Contains(ScheduledInvocationDisclosure.DedicatedCredential), + disclosure.Contains(ScheduledInvocationDisclosure.AevatarSecretCustody), + !disclosure.Contains(ScheduledInvocationDisclosure.BrowserNeverReceivesSecret), + disclosure.Contains(ScheduledInvocationDisclosure.DeleteRevokesCredential), + !disclosure.Contains(ScheduledInvocationDisclosure.PauseResumePreservesCredential)), + new ScheduledInvocationAuthorizationAuthority( + SourceVersion(plan, AuthorizationSourceKind.StudioMember), + SourceVersion(plan, AuthorizationSourceKind.WorkflowRevision), + SourceVersion(plan, AuthorizationSourceKind.ConnectorCatalog), + SourceVersion(plan, AuthorizationSourceKind.OwnerLlmRoute), + catalog?.ActorStateVersion ?? 0, + catalog?.ObservedAt?.ToDateTimeOffset() ?? default, + catalog?.FreshUntil?.ToDateTimeOffset() ?? default, + catalog?.ContentDigest ?? string.Empty, + catalog?.ContractVersion ?? string.Empty, + catalog?.PolicyVersion ?? string.Empty, + catalog?.EvaluatedAt?.ToDateTimeOffset() ?? default), + plan.OwnerLlmSelection?.Clone()); + } + + private static string ToScopeName(NyxIdCredentialScope scope) => scope switch + { + NyxIdCredentialScope.Read => "read", + NyxIdCredentialScope.Proxy => "proxy", + _ => throw new InvalidOperationException("scheduled_authorization_scope_invalid"), + }; + + private static long SourceVersion( + ScheduledInvocationAuthorizationPlan plan, + AuthorizationSourceKind sourceKind) => + plan.SourceStamps.FirstOrDefault(stamp => stamp.SourceKind == sourceKind)?.StateVersion ?? 0; + + private static ScheduledInvocationAuthorizationFact CloneScheduleAuthorizationFact( + ScheduledInvocationAuthorizationFact fact) => + new( + fact.PermissionDigest, + fact.PolicyVersion, + new ScheduledInvocationAuthorizationOwner( + fact.Owner.Authority, + fact.Owner.OwnerKind, + fact.Owner.OwnerSubject), + fact.ServiceGrants.Select(static grant => + new ScheduledInvocationAuthorizationServiceGrant( + grant.ServiceId, + grant.NodeIds.ToArray(), + grant.NodeGrantsNotRequired)).ToArray(), + fact.Scopes, + fact.ExpiresAt, + fact.ServiceGrantsNotRequired, + new ScheduledInvocationAuthorizationDisclosure( + fact.Disclosure.DedicatedToSchedule, + fact.Disclosure.SecretManagedByAevatar, + fact.Disclosure.BrowserReceivesRawKey, + fact.Disclosure.DeleteRevokesCredential, + fact.Disclosure.PauseResumeRevokesCredential), + new ScheduledInvocationAuthorizationAuthority( + fact.Authority.MemberStateVersion, + fact.Authority.WorkflowStateVersion, + fact.Authority.ConnectorStateVersion, + fact.Authority.OwnerLlmStateVersion, + fact.Authority.CatalogStateVersion, + fact.Authority.CatalogObservedAt, + fact.Authority.CatalogFreshUntil, + fact.Authority.CatalogContentDigest, + fact.Authority.CatalogContractVersion, + fact.Authority.CatalogPolicyVersion, + fact.Authority.CatalogEvaluatedAt), + fact.OwnerLLMSelection?.Clone()); + + private static string BuildTeamAutomationMutationDigest(TeamAutomationActivationDecision decision) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendDigestValue(hash, "aevatar.workflow-external-trigger-mutation.v1"); + AppendDigestValue(hash, decision.ScheduleId); + AppendDigestValue(hash, decision.DisplayName); + AppendDigestValue(hash, decision.Owner.ScopeId); + AppendDigestValue(hash, decision.Owner.MemberId); + AppendDigestValue(hash, decision.Owner.TeamId); + AppendDigestValue(hash, decision.ServiceIdentity.TenantId); + AppendDigestValue(hash, decision.ServiceIdentity.AppId); + AppendDigestValue(hash, decision.ServiceIdentity.Namespace); + AppendDigestValue(hash, decision.ServiceIdentity.ServiceId); + AppendDigestValue(hash, decision.EndpointId); + AppendDigestValue(hash, decision.Payload.TypeUrl); + AppendDigestBytes(hash, decision.Payload.Value.Span); + AppendDigestValue(hash, decision.CallerAuthority.Platform); + AppendDigestValue(hash, decision.CallerAuthority.Tenant); + AppendDigestValue(hash, decision.CallerAuthority.ExternalUserId); + AppendDigestValue(hash, decision.CallerAuthority.Scope); + AppendDigestValue(hash, decision.CallerAuthority.BindingId); + AppendAuthorizationFactDigest(hash, decision.AuthorizationFact); + AppendDigestValue(hash, decision.CronExpression); + AppendDigestValue(hash, decision.Timezone); + AppendDigestBoolean(hash, decision.Enabled); + AppendDigestInt64(hash, (long)decision.ScheduleKind); + AppendDigestInt64(hash, decision.Headers.Count); + foreach (var (key, value) in decision.Headers.OrderBy(static entry => entry.Key, StringComparer.Ordinal)) + { + AppendDigestValue(hash, key); + AppendDigestValue(hash, value); + } + AppendDigestInt64(hash, (long)decision.ScheduleMode); + AppendDigestBoolean(hash, decision.OneShotFireAt.HasValue); + if (decision.OneShotFireAt.HasValue) + AppendDigestInt64(hash, decision.OneShotFireAt.Value.ToUniversalTime().UtcTicks); + AppendDigestInt64(hash, (long)decision.CredentialRequirementTargetKind); + AppendDigestValue(hash, decision.RevisionId); + AppendDigestBoolean(hash, decision.Caller != null); + if (decision.Caller != null) + { + AppendDigestValue(hash, decision.Caller.ServiceKey); + AppendDigestValue(hash, decision.Caller.TenantId); + AppendDigestValue(hash, decision.Caller.AppId); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static void AppendAuthorizationFactDigest( + IncrementalHash hash, + ScheduledInvocationAuthorizationFact fact) + { + AppendDigestValue(hash, fact.PermissionDigest); + AppendDigestValue(hash, fact.PolicyVersion); + AppendDigestValue(hash, fact.Owner.Authority); + AppendDigestValue(hash, fact.Owner.OwnerKind); + AppendDigestValue(hash, fact.Owner.OwnerSubject); + var grants = fact.ServiceGrants + .OrderBy(static grant => grant.ServiceId, StringComparer.Ordinal) + .ThenBy(static grant => grant.NodeGrantsNotRequired) + .ThenBy(static grant => string.Join('\n', grant.NodeIds.Order(StringComparer.Ordinal)), StringComparer.Ordinal) + .ToArray(); + AppendDigestInt64(hash, grants.Length); + foreach (var grant in grants) + { + AppendDigestValue(hash, grant.ServiceId); + AppendDigestBoolean(hash, grant.NodeGrantsNotRequired); + var nodeIds = grant.NodeIds.Order(StringComparer.Ordinal).ToArray(); + AppendDigestInt64(hash, nodeIds.Length); + foreach (var nodeId in nodeIds) + AppendDigestValue(hash, nodeId); + } + AppendDigestValue(hash, fact.Scopes); + AppendDigestInt64(hash, fact.ExpiresAt.ToUniversalTime().UtcTicks); + AppendDigestBoolean(hash, fact.ServiceGrantsNotRequired); + AppendDigestBoolean(hash, fact.Disclosure.DedicatedToSchedule); + AppendDigestBoolean(hash, fact.Disclosure.SecretManagedByAevatar); + AppendDigestBoolean(hash, fact.Disclosure.BrowserReceivesRawKey); + AppendDigestBoolean(hash, fact.Disclosure.DeleteRevokesCredential); + AppendDigestBoolean(hash, fact.Disclosure.PauseResumeRevokesCredential); + AppendDigestInt64(hash, fact.Authority.MemberStateVersion); + AppendDigestInt64(hash, fact.Authority.WorkflowStateVersion); + AppendDigestInt64(hash, fact.Authority.ConnectorStateVersion); + AppendDigestInt64(hash, fact.Authority.OwnerLlmStateVersion); + AppendDigestInt64(hash, fact.Authority.CatalogStateVersion); + AppendDigestInt64(hash, fact.Authority.CatalogObservedAt.ToUniversalTime().UtcTicks); + AppendDigestInt64(hash, fact.Authority.CatalogFreshUntil.ToUniversalTime().UtcTicks); + AppendDigestValue(hash, fact.Authority.CatalogContentDigest); + AppendDigestValue(hash, fact.Authority.CatalogContractVersion); + AppendDigestValue(hash, fact.Authority.CatalogPolicyVersion); + AppendDigestInt64(hash, fact.Authority.CatalogEvaluatedAt.ToUniversalTime().UtcTicks); + AppendDigestBoolean(hash, fact.OwnerLLMSelection != null); + if (fact.OwnerLLMSelection != null) + { + AppendDigestInt64(hash, (long)fact.OwnerLLMSelection.RouteKind); + AppendDigestValue(hash, fact.OwnerLLMSelection.RouteValue); + AppendDigestValue(hash, fact.OwnerLLMSelection.NyxIdUserServiceId); + AppendDigestValue(hash, fact.OwnerLLMSelection.ServiceSlugSnapshot); + AppendDigestValue(hash, fact.OwnerLLMSelection.Model); + } + } + + private static void AppendDigestValue(IncrementalHash hash, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + Span length = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); + hash.AppendData(length); + hash.AppendData(bytes); + } + + private static void AppendDigestBytes(IncrementalHash hash, ReadOnlySpan bytes) + { + Span length = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); + hash.AppendData(length); + hash.AppendData(bytes); + } + + private static void AppendDigestBoolean(IncrementalHash hash, bool value) => + hash.AppendData(value ? [1] : [0]); + + private static void AppendDigestInt64(IncrementalHash hash, long value) + { + Span bytes = stackalloc byte[sizeof(long)]; + BinaryPrimitives.WriteInt64BigEndian(bytes, value); + hash.AppendData(bytes); + } + + private static void EnsureRequiredDisclosures(ScheduledInvocationAuthorizationPlan plan) + { + var disclosures = plan.Disclosures.ToHashSet(); + var required = new[] + { + ScheduledInvocationDisclosure.DedicatedCredential, + ScheduledInvocationDisclosure.AevatarSecretCustody, + ScheduledInvocationDisclosure.BrowserNeverReceivesSecret, + ScheduledInvocationDisclosure.DeleteRevokesCredential, + ScheduledInvocationDisclosure.PauseResumePreservesCredential, + }; + if (required.Any(disclosure => !disclosures.Contains(disclosure))) + throw new InvalidOperationException("scheduled_authorization_disclosures_missing"); + } + + private static void EnsureCredentialMatchesPlan( + StudioScheduledCredential credential, + ScheduledInvocationAuthorizationPlan plan, + DateTimeOffset now) + { + if (credential.ExpiresAtUtc <= now || + credential.ExpiresAtUtc > plan.CredentialPolicy.ExpiresAt.ToDateTimeOffset()) + throw new InvalidOperationException("scheduled_credential_expiry_mismatch"); + if (!string.Equals( + credential.SecretReference.Purpose, + CredentialSecretPurposes.ScheduledInvocationAgentKey, + StringComparison.Ordinal)) + throw new InvalidOperationException("scheduled_credential_purpose_mismatch"); + var owner = new ScheduledInvocationAuthorizationOwner( + plan.Owner.Authority, + plan.Owner.OwnerKind.ToString(), + plan.Owner.OwnerSubject); + if (credential.Owner != owner) + throw new InvalidOperationException("credential_owner_mismatch"); + } + + private static ScheduledInvocationAuthorizationOwner ToAuthorizationOwner( + AuthenticatedAuthorizationOwnerContext authenticatedOwner) => + new( + NormalizeRequired(authenticatedOwner.Owner.Authority, nameof(authenticatedOwner.Owner.Authority)), + authenticatedOwner.Owner.OwnerKind.ToString(), + NormalizeRequired(authenticatedOwner.Owner.OwnerSubject, nameof(authenticatedOwner.Owner.OwnerSubject))); + + private static OwnerScope BuildOwnerScope(AuthenticatedAuthorizationOwnerContext owner) => + string.Equals(owner.SubjectPlatform, OwnerScope.NyxIdPlatform, StringComparison.Ordinal) + ? OwnerScope.ForNyxIdNative(owner.Owner.OwnerSubject) + : OwnerScope.ForChannel( + owner.Owner.OwnerSubject, + owner.SubjectPlatform.Trim().ToLowerInvariant(), + NormalizeRequired(owner.SubjectTenant, nameof(owner.SubjectTenant)), + owner.SubjectExternalUserId); + + private static string BuildOperationIdentity( + TeamAutomationOperationKind kind, + string scheduleId, + string permissionDigest) + { + var identity = Encoding.UTF8.GetBytes($"{kind}\n{scheduleId}\n{permissionDigest}"); + return $"workflow-external-trigger:{Convert.ToHexStringLower(SHA256.HashData(identity).AsSpan(0, 16))}"; + } + + private static string ToStableFailureCode(Exception exception) => exception switch + { + OperationCanceledException => "operation_cancelled", + InvalidOperationException { Message: { Length: > 0 } message } when IsStableErrorCode(message) => message, + _ => "workflow_external_trigger_credential_apply_failed", + }; + + private static bool IsStableErrorCode(string value) => + value.Length <= 128 && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-' or '.'); + + private static string NormalizeRequired(string? value, string paramName) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) + ? throw new ArgumentException($"{paramName} is required.", paramName) + : normalized; + } + + private static string? NormalizeOptional(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index 967c7cae87..effe8277b6 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -5,6 +5,7 @@ using Aevatar.AI.Abstractions; using Aevatar.Capabilities; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Abstractions.Schedules; @@ -106,6 +107,8 @@ internal static async Task UpsertExternalTrigger( WorkflowExternalTriggerConfigurationHttpRequest input, [FromServices] IScopeWorkflowQueryPort workflowQueryPort, [FromServices] IScheduledDispatchApplicationService schedules, + [FromServices] IWorkflowExternalTriggerProvisioningPort externalTriggerProvisioning, + [FromServices] IExternalIdentityBindingQueryPort bindingQuery, CancellationToken ct = default) { var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); @@ -149,18 +152,28 @@ internal static async Task UpsertExternalTrigger( try { - var receipt = await schedules.EnsureAsync(configuration, context, ct); - var response = WorkflowExternalTriggerHttpResult.FromMutation( + var authority = await StudioMemberAutomationHttpAuthorityResolver.ResolveAsync( + http, + bindingQuery, + context.AuthenticatedNyxIdOwnerSubject?.ExternalUserId, + ct); + var provisioned = await externalTriggerProvisioning.ProvisionAsync( resolved.Workflow!, configuration, - receipt, - ScheduledDispatchCredentialSourceKind.ScopeOwnerNyxId, - CredentialExpiresAt: null, - PermissionDigest: string.Empty, - PolicyVersion: string.Empty); - return Results.Accepted(BuildExternalTriggerLocation(scopeId, receipt.ScheduleId), response); + context, + authority, + ct); + var response = WorkflowExternalTriggerHttpResult.FromMutation( + resolved.Workflow!, + provisioned.Configuration, + provisioned.Receipt, + provisioned.CredentialSourceKind, + provisioned.CredentialExpiresAt, + provisioned.PermissionDigest, + provisioned.PolicyVersion); + return Results.Accepted(BuildExternalTriggerLocation(scopeId, provisioned.Receipt.ScheduleId), response); } - catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out var result)) + catch (Exception ex) when (TryMapExternalTriggerProvisioningError(ex, out var result)) { return result; } @@ -818,7 +831,7 @@ private static string BuildExternalTriggerId(ScopeWorkflowSummary workflow) var triggerKey = string.Join( ":", workflow.ScopeId.Trim(), - workflow.DefinitionActorId.Trim(), + workflow.ActorId.Trim(), workflow.PublishedServiceId.Trim()); var triggerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(triggerKey))) .ToLowerInvariant()[..32]; @@ -828,6 +841,35 @@ private static string BuildExternalTriggerId(ScopeWorkflowSummary workflow) private static string BuildExternalTriggerLocation(string scopeId, string triggerId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflow-triggers/{Uri.EscapeDataString(triggerId)}:fire"; + private static bool TryMapExternalTriggerProvisioningError(Exception ex, out IResult result) + { + if (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out result)) + return true; + + switch (ex) + { + case UnauthorizedAccessException unauthorized: + result = Results.Json( + new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_AUTHORIZATION_REQUIRED", + message = unauthorized.Message, + }, + statusCode: StatusCodes.Status401Unauthorized); + return true; + case InvalidOperationException invalid: + result = Results.BadRequest(new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_PROVISIONING_FAILED", + message = invalid.Message, + }); + return true; + default: + result = Results.Empty; + return false; + } + } + private static bool IsWorkflowExternalTriggerForScope( ScheduledDispatchSummary schedule, string scopeId, diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index 3323dcf0d6..ea6ae5a8a4 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -7,12 +7,16 @@ using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Abstractions.Queries; using Aevatar.GAgentService.Abstractions.Schedules; +using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.GAgentService.Abstractions.Services; using Aevatar.GAgentService.Application.Workflows; using Aevatar.GAgentService.Governance.Abstractions; using Aevatar.GAgentService.Governance.Abstractions.Ports; using Aevatar.GAgentService.Governance.Abstractions.Queries; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgentService.Hosting.Endpoints; +using Aevatar.GAgentService.Hosting.Endpoints.Schedules; using Aevatar.Studio.Application; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; @@ -1672,11 +1676,14 @@ public async Task HandleUpsertWorkflowAsync_ShouldReturnAccepted_WithLocation_Wh public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServerOwnedCredential() { var http = CreateHttpContext("scope-alpha"); + http.Request.Headers.Authorization = "Bearer transient-provisioning-token"; var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), }; var schedules = new RecordingWorkflowScheduledDispatchService(); + var provisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); + var bindingQuery = new FakeExternalIdentityBindingQueryPort(); var input = new WorkflowExternalTriggerConfigurationHttpRequest { DisplayName = "External trigger", @@ -1692,6 +1699,8 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe input, workflowQueryPort, schedules, + provisioner, + bindingQuery, CancellationToken.None); await result.ExecuteAsync(http); @@ -1701,18 +1710,26 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe body.Should().Contain("\"configured\":false"); body.Should().Contain("\"status\":\"pending\""); body.Should().Contain("\"acceptanceStage\":\"accepted\""); - schedules.Ensured.Should().ContainSingle(); - schedules.EnsureContexts.Should().ContainSingle().Which!.AuthenticatedNyxIdOwnerSubject + body.Should().Contain("\"credentialSourceKind\":\"ScheduledInvocationAgentKey\""); + body.Should().Contain("\"agentKeyReady\":true"); + schedules.Ensured.Should().BeEmpty(); + provisioner.Configurations.Should().ContainSingle(); + provisioner.Contexts.Should().ContainSingle().Which!.AuthenticatedNyxIdOwnerSubject .Should().NotBeNull(); - var configuration = schedules.Ensured[0]; - configuration.ScheduleId.Should().StartWith("workflow-trigger-scope-alpha-"); + provisioner.Authorities.Should().ContainSingle().Which.AuthenticatedOwner.VerifiedBindingId + .Should().Be("binding-caller-alpha"); + var configuration = provisioner.Configurations[0]; + configuration.ScheduleId.Should().StartWith("workflow-trigger-"); + configuration.ScheduleId.Should().HaveLength("workflow-trigger-".Length + 32); configuration.Target.ServiceInvocation!.Auth!.Source .Should().BeOfType(); configuration.Target.ServiceInvocation.Payload.Unpack().Prompt .Should().Be("run workflow"); var secondHttp = CreateHttpContext("scope-alpha"); + secondHttp.Request.Headers.Authorization = "Bearer transient-provisioning-token"; var secondSchedules = new RecordingWorkflowScheduledDispatchService(); + var secondProvisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); var secondResult = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( secondHttp, "scope-alpha", @@ -1720,12 +1737,15 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe input, workflowQueryPort, secondSchedules, + secondProvisioner, + bindingQuery, CancellationToken.None); await secondResult.ExecuteAsync(secondHttp); - secondSchedules.Ensured.Should().ContainSingle(); - secondSchedules.Ensured[0].ScheduleId.Should().Be(configuration.ScheduleId); + secondSchedules.Ensured.Should().BeEmpty(); + secondProvisioner.Configurations.Should().ContainSingle(); + secondProvisioner.Configurations[0].ScheduleId.Should().Be(configuration.ScheduleId); } [Fact] @@ -1733,6 +1753,7 @@ public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithou { var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); var http = CreateHttpContext("scope-alpha"); + http.Request.Headers.Authorization = "Bearer transient-provisioning-token"; var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), @@ -1753,6 +1774,8 @@ public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithou }, []), }; + var provisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); + var bindingQuery = new FakeExternalIdentityBindingQueryPort(); var result = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( http, @@ -1761,6 +1784,8 @@ public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithou new WorkflowExternalTriggerConfigurationHttpRequest { Prompt = "run workflow" }, workflowQueryPort, schedules, + provisioner, + bindingQuery, CancellationToken.None); await result.ExecuteAsync(http); @@ -1769,6 +1794,7 @@ public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithou http.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); body.Should().Contain("WORKFLOW_EXTERNAL_TRIGGER_ID_CONFLICT"); schedules.Ensured.Should().BeEmpty(); + provisioner.Configurations.Should().BeEmpty(); } [Fact] @@ -1795,7 +1821,8 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindin http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); body.Should().Contain("\"configured\":false"); body.Should().Contain("\"status\":\"not_configured\""); - schedules.LastScheduleGet.Should().StartWith("workflow-trigger-scope-alpha-"); + schedules.LastScheduleGet.Should().StartWith("workflow-trigger-"); + schedules.LastScheduleGet.Should().HaveLength("workflow-trigger-".Length + 32); } [Fact] @@ -1834,7 +1861,7 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredBinding() http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); body.Should().Contain("\"configured\":true"); body.Should().Contain($"\"triggerId\":\"{triggerId}\""); - body.Should().Contain("\"credentialSourceKind\":\"scheduledInvocationAgentKey\""); + body.Should().Contain("\"credentialSourceKind\":\"ScheduledInvocationAgentKey\""); body.Should().Contain("\"agentKeyReady\":true"); body.Should().Contain("\"permissionDigest\":\"digest-alpha\""); } @@ -2778,6 +2805,49 @@ public Task LookupCatalogueByWorkflowIdAsync } } + private sealed class RecordingWorkflowExternalTriggerProvisioningPort : IWorkflowExternalTriggerProvisioningPort + { + public List Workflows { get; } = []; + public List Configurations { get; } = []; + public List Contexts { get; } = []; + public List Authorities { get; } = []; + + public Task ProvisionAsync( + ScopeWorkflowSummary workflow, + ScheduledDispatchConfiguration configuration, + ScheduledDispatchMutationContext context, + StudioMemberAutomationHttpAuthority authority, + CancellationToken ct = default) + { + Workflows.Add(workflow); + Configurations.Add(configuration); + Contexts.Add(context); + Authorities.Add(authority); + return Task.FromResult(new WorkflowExternalTriggerProvisioningResult( + new ScheduledDispatchMutationReceipt( + configuration.ScheduleId, + $"actor:{configuration.ScheduleId}", + true, + "cmd-external-trigger", + "corr-external-trigger", + DateTimeOffset.UtcNow, + "accepted"), + configuration, + ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + DateTimeOffset.UtcNow.AddDays(30), + "permission-digest-alpha", + ScheduledInvocationAuthorizationContractVersions.CredentialPolicy)); + } + } + + private sealed class FakeExternalIdentityBindingQueryPort : IExternalIdentityBindingQueryPort + { + public Task ResolveAsync( + ExternalSubjectRef externalSubject, + CancellationToken ct = default) => + Task.FromResult(new BindingId { Value = $"binding-{externalSubject.ExternalUserId}" }); + } + private sealed class RecordingWorkflowScheduledDispatchService : IScheduledDispatchApplicationService { public List Created { get; } = []; From 919108e0ea4ca175c8b33b97728e2e130743f0d1 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Thu, 8 Oct 2026 22:42:08 +0800 Subject: [PATCH 5/9] Allow external trigger provisioning in partial hosts. Co-Authored-By: Claude Opus 4.6 --- ...kflowExternalTriggerProvisioningService.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs index 4faa4afc66..ce04ef6d35 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs @@ -43,8 +43,8 @@ internal sealed class WorkflowExternalTriggerProvisioningService : IWorkflowExte private readonly IScheduledDispatchApplicationService _scheduleService; private readonly IScheduledInvocationAuthorizationPlanner _authorizationPlanner; private readonly IScheduledInvocationAuthorizationRevalidator _authorizationRevalidator; - private readonly IScheduledInvocationWorkflowEvidenceQueryPort _workflowEvidenceQueryPort; - private readonly IStudioScheduledCredentialMaterializer _credentialMaterializer; + private readonly IScheduledInvocationWorkflowEvidenceQueryPort? _workflowEvidenceQueryPort; + private readonly IStudioScheduledCredentialMaterializer? _credentialMaterializer; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -52,16 +52,16 @@ public WorkflowExternalTriggerProvisioningService( IScheduledDispatchApplicationService scheduleService, IScheduledInvocationAuthorizationPlanner authorizationPlanner, IScheduledInvocationAuthorizationRevalidator authorizationRevalidator, - IScheduledInvocationWorkflowEvidenceQueryPort workflowEvidenceQueryPort, - IStudioScheduledCredentialMaterializer credentialMaterializer, TimeProvider timeProvider, - ILogger? logger = null) + ILogger? logger = null, + IStudioScheduledCredentialMaterializer? credentialMaterializer = null, + IScheduledInvocationWorkflowEvidenceQueryPort? workflowEvidenceQueryPort = null) { _scheduleService = scheduleService ?? throw new ArgumentNullException(nameof(scheduleService)); _authorizationPlanner = authorizationPlanner ?? throw new ArgumentNullException(nameof(authorizationPlanner)); _authorizationRevalidator = authorizationRevalidator ?? throw new ArgumentNullException(nameof(authorizationRevalidator)); - _workflowEvidenceQueryPort = workflowEvidenceQueryPort ?? throw new ArgumentNullException(nameof(workflowEvidenceQueryPort)); - _credentialMaterializer = credentialMaterializer ?? throw new ArgumentNullException(nameof(credentialMaterializer)); + _workflowEvidenceQueryPort = workflowEvidenceQueryPort; + _credentialMaterializer = credentialMaterializer; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? NullLogger.Instance; } @@ -80,6 +80,9 @@ public async Task ProvisionAsync( var scheduleId = NormalizeRequired(configuration.ScheduleId, nameof(configuration.ScheduleId)); var serviceInvocation = configuration.Target.ServiceInvocation ?? throw new ArgumentException("External trigger target must be a service invocation.", nameof(configuration)); + if (_workflowEvidenceQueryPort == null) + throw new InvalidOperationException("workflow_authorization_evidence_query_not_configured"); + var workflowEvidence = await _workflowEvidenceQueryPort.GetAsync( workflow.ScopeId, workflow.PublishedServiceId, @@ -105,6 +108,9 @@ public async Task ProvisionAsync( if (!validation.Success) throw new InvalidOperationException(validation.Detail); + if (_credentialMaterializer == null) + throw new InvalidOperationException("workflow_external_trigger_credential_materializer_not_configured"); + var validatedPlan = validation.ValidatedPlan!; var authorizationFact = ToScheduleAuthorizationFact(validatedPlan.Plan); var callerAuthority = BuildScheduleCallerAuthority(authority.AuthenticatedOwner); @@ -484,9 +490,11 @@ private async Task RevokePendingCrede DateTimeOffset.FromUnixTimeMilliseconds(pending.KeyExpiresAtUnixMs), outcome.PendingRevocationOwner!, pending.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); + var credentialMaterializer = _credentialMaterializer + ?? throw new InvalidOperationException("workflow_external_trigger_credential_materializer_not_configured"); try { - return await _credentialMaterializer.RevokeAsync( + return await credentialMaterializer.RevokeAsync( bearerToken, authenticatedOwner, credential, From 104eb923bf0bcc54566f7abb969cf3e19d0a8e78 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 9 Oct 2026 00:47:12 +0800 Subject: [PATCH 6/9] Increase coverage job timeout Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6113ef8768..95801af973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -394,10 +394,10 @@ jobs: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || needs.changes.outputs.docs_only == 'false' runs-on: ubuntu-latest - # Full coverage takes 34-35 minutes after a roughly 10-minute restore/build, - # then still needs time to upload the report. Keep the fail-fast guard while - # leaving enough headroom for runner variance and cleanup. - timeout-minutes: 55 + # Full coverage takes roughly 40 minutes after restore/build, + # then still needs time to generate and upload the report. Keep the fail-fast + # guard while leaving enough headroom for runner variance and cleanup. + timeout-minutes: 75 services: agent-tool-admission-redis: image: redis:7.2.3 From 20d1cd104ce24fc6942dfaf4bc23b6bc40ad5432 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 9 Oct 2026 10:58:07 +0800 Subject: [PATCH 7/9] Use verified caller tokens for external triggers. Co-Authored-By: Claude Opus 4.6 --- ...kflowExternalTriggerProvisioningService.cs | 54 +- .../ScopeWorkflowScheduleEndpoints.cs | 46 +- .../ScopeWorkflowEndpointsTests.cs | 534 +++++++++++++++++- 3 files changed, 607 insertions(+), 27 deletions(-) diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs index ce04ef6d35..50e9f475c2 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs @@ -10,6 +10,7 @@ using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.Studio.Application.Provisioning; using Aevatar.Workflow.Abstractions; +using Aevatar.Workflow.Abstractions.Credentials; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -45,6 +46,7 @@ internal sealed class WorkflowExternalTriggerProvisioningService : IWorkflowExte private readonly IScheduledInvocationAuthorizationRevalidator _authorizationRevalidator; private readonly IScheduledInvocationWorkflowEvidenceQueryPort? _workflowEvidenceQueryPort; private readonly IStudioScheduledCredentialMaterializer? _credentialMaterializer; + private readonly IWorkflowCallerAccessTokenProvider? _callerAccessTokenProvider; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -55,13 +57,15 @@ public WorkflowExternalTriggerProvisioningService( TimeProvider timeProvider, ILogger? logger = null, IStudioScheduledCredentialMaterializer? credentialMaterializer = null, - IScheduledInvocationWorkflowEvidenceQueryPort? workflowEvidenceQueryPort = null) + IScheduledInvocationWorkflowEvidenceQueryPort? workflowEvidenceQueryPort = null, + IWorkflowCallerAccessTokenProvider? callerAccessTokenProvider = null) { _scheduleService = scheduleService ?? throw new ArgumentNullException(nameof(scheduleService)); _authorizationPlanner = authorizationPlanner ?? throw new ArgumentNullException(nameof(authorizationPlanner)); _authorizationRevalidator = authorizationRevalidator ?? throw new ArgumentNullException(nameof(authorizationRevalidator)); _workflowEvidenceQueryPort = workflowEvidenceQueryPort; _credentialMaterializer = credentialMaterializer; + _callerAccessTokenProvider = callerAccessTokenProvider; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? NullLogger.Instance; } @@ -114,6 +118,7 @@ public async Task ProvisionAsync( var validatedPlan = validation.ValidatedPlan!; var authorizationFact = ToScheduleAuthorizationFact(validatedPlan.Plan); var callerAuthority = BuildScheduleCallerAuthority(authority.AuthenticatedOwner); + var provisioningBearerToken = await ResolveProvisioningBearerTokenAsync(authority, ct); var owner = BuildOwner(workflow); var existing = await _scheduleService.GetAsync(scheduleId, ct); var operationKind = existing?.Schedule.CredentialSourceKind == @@ -182,7 +187,7 @@ public async Task ProvisionAsync( began.Outcome.CandidateCredential!, began.Outcome.CandidateOwner) : await _credentialMaterializer.MaterializeAsync( - authority.ProvisioningBearerToken, + provisioningBearerToken, validatedPlan, scheduleId, operationId, @@ -236,7 +241,7 @@ public async Task ProvisionAsync( throw new InvalidOperationException("team_automation_activation_rejected"); _ = await ExecutePendingRevocationAsync( activation.Outcome, - authority.ProvisioningBearerToken, + provisioningBearerToken, authority.AuthenticatedOwner, owner, CancellationToken.None); @@ -250,7 +255,7 @@ public async Task ProvisionAsync( } catch (Exception ex) { - if (candidateCommitted && !activationAttempted) + if (!activationAttempted) { _ = await TryRecordFailureAsync( scheduleId, @@ -261,12 +266,13 @@ public async Task ProvisionAsync( ToStableFailureCode(ex), CancellationToken.None); } - else if (!candidateCommitAttempted && credential != null) + + if (!candidateCommitAttempted && credential != null) { try { _ = await _credentialMaterializer.RevokeAsync( - authority.ProvisioningBearerToken, + provisioningBearerToken, authority.AuthenticatedOwner, credential, revokeNyxId: true, @@ -477,6 +483,42 @@ private async Task ExecutePendingRevocationAsync( return completion.Admission.Accepted && result.NyxIdRevoked && result.VaultRevoked; } + private async Task ResolveProvisioningBearerTokenAsync( + StudioMemberAutomationHttpAuthority authority, + CancellationToken ct) + { + if (_callerAccessTokenProvider != null) + { + var callerAuthority = BuildWorkflowCallerAuthority(authority.AuthenticatedOwner); + var issued = await _callerAccessTokenProvider.IssueAsync(callerAuthority, ct); + var issuedToken = WorkflowCallerCredentialTokens.ParseOptional(issued); + return issuedToken.IsValid + ? issuedToken.NormalizedBearerToken! + : throw new InvalidOperationException("workflow_caller_access_token_provider_returned_invalid_token"); + } + + var parsed = WorkflowCallerCredentialTokens.ParseOptional(authority.ProvisioningBearerToken); + return parsed.IsValid + ? parsed.NormalizedBearerToken! + : throw new UnauthorizedAccessException("provisioning_bearer_invalid"); + } + + private static WorkflowCallerNyxIdAuthority BuildWorkflowCallerAuthority( + AuthenticatedAuthorizationOwnerContext owner) + { + var subjectPlatform = NormalizeRequired(owner.SubjectPlatform, nameof(owner.SubjectPlatform)); + var subjectExternalUserId = NormalizeRequired(owner.SubjectExternalUserId, nameof(owner.SubjectExternalUserId)); + var bindingId = NormalizeRequired(owner.VerifiedBindingId, nameof(owner.VerifiedBindingId)); + return new WorkflowCallerNyxIdAuthority + { + Platform = subjectPlatform, + Tenant = NormalizeOptional(owner.SubjectTenant) ?? string.Empty, + ExternalUserId = subjectExternalUserId, + Scope = ProvisioningBearerCapabilityScope, + BindingId = bindingId, + }; + } + private async Task RevokePendingCredentialAsync( string bearerToken, AuthenticatedAuthorizationOwnerContext authenticatedOwner, diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index effe8277b6..9b7576426e 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -23,6 +23,7 @@ internal static class ScopeWorkflowScheduleEndpoints { private const string ChatEndpointId = "chat"; private const string DefaultWorkflowScheduleNyxIdScope = "proxy"; + private const string ExternalTriggerTeamId = "workflow-external-trigger"; public static RouteGroupBuilder MapScopeWorkflowScheduleEndpoints(this RouteGroupBuilder group) { @@ -192,7 +193,10 @@ internal static async Task GetExternalTrigger( return resolved.Result; var triggerId = BuildExternalTriggerId(resolved.Workflow!); - var detail = await schedules.GetAsync(triggerId, ct); + var detail = await schedules.GetTeamAutomationAsync( + triggerId, + BuildExternalTriggerOwner(resolved.Workflow!), + ct); if (detail == null || !BelongsToWorkflow(detail.Schedule, resolved.Workflow!)) { return Results.Ok(WorkflowExternalTriggerHttpResult.NotConfigured( @@ -218,7 +222,11 @@ internal static async Task FireExternalTrigger( if (TryCreateInvalidScheduleIdResult(triggerId, out var invalidTriggerId)) return invalidTriggerId; - var detail = await schedules.GetAsync(triggerId, ct); + var detail = await schedules.GetTeamScheduleAsync( + triggerId, + scopeId, + ExternalTriggerTeamId, + ct: ct); if (detail == null || !IsWorkflowExternalTriggerForScope(detail.Schedule, scopeId, triggerId)) { return Results.NotFound(new @@ -228,17 +236,12 @@ internal static async Task FireExternalTrigger( }); } + var owner = BuildExternalTriggerOwner(detail.Schedule); try { - var receipt = await schedules.RunNowAsync( + var receipt = await schedules.RunTeamAutomationNowAsync( triggerId, - new ScheduledDispatchMutationContext( - scopeId, - ExpectedServiceTarget: new ScheduledDispatchExpectedServiceTarget( - ScheduledDispatchScheduleKind.Workflow, - ScheduledDispatchTargetKind.ServiceInvocation, - detail.Schedule.ServiceIdentity, - ChatEndpointId)), + owner, ct); return Results.Accepted( BuildExternalTriggerLocation(scopeId, triggerId), @@ -794,6 +797,11 @@ private static bool TryCreateInvalidScheduleIdResult(string? scheduleId, out IRe return false; } + private static string NormalizeRequired(string? value, string paramName) => + string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException($"{paramName} is required.", paramName) + : value.Trim(); + private static ScheduledServiceInvocationNyxIdSubjectRef? ResolveAuthenticatedNyxIdOwnerSubject(HttpContext http) { var ownerUserId = ReadFirstClaim( @@ -841,6 +849,18 @@ private static string BuildExternalTriggerId(ScopeWorkflowSummary workflow) private static string BuildExternalTriggerLocation(string scopeId, string triggerId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflow-triggers/{Uri.EscapeDataString(triggerId)}:fire"; + private static TeamMemberAutomationOwner BuildExternalTriggerOwner(ScopeWorkflowSummary workflow) => + new( + NormalizeRequired(workflow.ScopeId, nameof(workflow.ScopeId)), + NormalizeRequired(workflow.WorkflowId, nameof(workflow.WorkflowId)), + ExternalTriggerTeamId); + + private static TeamMemberAutomationOwner BuildExternalTriggerOwner(ScheduledDispatchSummary schedule) => + new( + NormalizeRequired(schedule.TeamOwnerScopeId, nameof(schedule.TeamOwnerScopeId)), + NormalizeRequired(schedule.TeamOwnerMemberId, nameof(schedule.TeamOwnerMemberId)), + NormalizeRequired(schedule.TeamId, nameof(schedule.TeamId))); + private static bool TryMapExternalTriggerProvisioningError(Exception ex, out IResult result) { if (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out result)) @@ -875,10 +895,14 @@ private static bool IsWorkflowExternalTriggerForScope( string scopeId, string triggerId) => schedule.ScheduleId == triggerId && + schedule.TeamOwned && + string.Equals(schedule.TeamId, ExternalTriggerTeamId, StringComparison.Ordinal) && + string.Equals(schedule.TeamOwnerScopeId, scopeId, StringComparison.Ordinal) && schedule.ScheduleKind == ScheduledDispatchScheduleKind.Workflow && schedule.TargetKind == ScheduledDispatchTargetKind.ServiceInvocation && string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal) && - string.Equals(schedule.ServiceIdentity.TenantId, scopeId, StringComparison.Ordinal); + string.Equals(schedule.ServiceIdentity.TenantId, scopeId, StringComparison.Ordinal) && + schedule.CredentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey; private static string BuildWorkflowScheduleLocation(string scopeId, string workflowId, string scheduleId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflows/{Uri.EscapeDataString(workflowId)}/schedules/{Uri.EscapeDataString(scheduleId)}"; diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index ea6ae5a8a4..ff6f419ab5 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Security.Cryptography; using System.Text; using Aevatar.AI.Abstractions; @@ -18,12 +19,17 @@ using Aevatar.GAgentService.Hosting.Endpoints; using Aevatar.GAgentService.Hosting.Endpoints.Schedules; using Aevatar.Studio.Application; +using Aevatar.Studio.Application.Provisioning; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Connectors; +using Aevatar.Foundation.Abstractions.Credentials; using Aevatar.CQRS.Core.Abstractions.Commands; using Aevatar.Workflow.Abstractions; +using Aevatar.Workflow.Abstractions.Credentials; using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; +using CredentialWorkflowCallerAuthority = Aevatar.Workflow.Abstractions.WorkflowCallerNyxIdAuthority; using Aevatar.Workflow.Application.Abstractions.Runs; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Http; @@ -1748,6 +1754,79 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe secondProvisioner.Configurations[0].ScheduleId.Should().Be(configuration.ScheduleId); } + [Fact] + public async Task WorkflowExternalTriggerProvisioning_ShouldIssueProvisioningTokenFromVerifiedBinding() + { + var schedules = new RecordingWorkflowScheduledDispatchService(); + var planner = new RecordingExternalTriggerAuthorizationPlanner(); + var revalidator = new RecordingExternalTriggerAuthorizationRevalidator(planner); + var materializer = new RecordingExternalTriggerCredentialMaterializer(); + var tokenProvider = new RecordingWorkflowCallerAccessTokenProvider(); + var service = new WorkflowExternalTriggerProvisioningService( + schedules, + planner, + revalidator, + TimeProvider.System, + credentialMaterializer: materializer, + workflowEvidenceQueryPort: new RecordingWorkflowEvidenceQueryPort(), + callerAccessTokenProvider: tokenProvider); + var authority = ExternalTriggerAuthority(); + + await service.ProvisionAsync( + RunnableWorkflow().Workflow!, + ExternalTriggerConfiguration(ExternalTriggerId("scope-alpha", "wf-alpha")), + new ScheduledDispatchMutationContext(AuthenticatedScopeId: "scope-alpha"), + authority, + CancellationToken.None); + + tokenProvider.Authorities.Should().ContainSingle().Which.Should().BeEquivalentTo(new CredentialWorkflowCallerAuthority + { + Platform = OwnerScope.NyxIdPlatform, + Tenant = string.Empty, + ExternalUserId = "caller-alpha", + Scope = "proxy", + BindingId = "binding-caller-alpha", + }); + materializer.BearerToken.Should().Be("issued-provisioning-token"); + materializer.BearerToken.Should().NotBe(authority.ProvisioningBearerToken); + schedules.CompletedCredentialOperations.Should().ContainSingle(); + } + + [Fact] + public async Task WorkflowExternalTriggerProvisioning_WhenMaterializationFails_ShouldRecordOperationFailure() + { + var schedules = new RecordingWorkflowScheduledDispatchService(); + var planner = new RecordingExternalTriggerAuthorizationPlanner(); + var revalidator = new RecordingExternalTriggerAuthorizationRevalidator(planner); + var materializer = new RecordingExternalTriggerCredentialMaterializer( + new InvalidOperationException("nyxid_api_key_list_unauthorized")); + var service = new WorkflowExternalTriggerProvisioningService( + schedules, + planner, + revalidator, + TimeProvider.System, + credentialMaterializer: materializer, + workflowEvidenceQueryPort: new RecordingWorkflowEvidenceQueryPort()); + var configuration = ExternalTriggerConfiguration(ExternalTriggerId("scope-alpha", "wf-alpha")); + var authority = ExternalTriggerAuthority(); + + var act = () => service.ProvisionAsync( + RunnableWorkflow().Workflow!, + configuration, + new ScheduledDispatchMutationContext(AuthenticatedScopeId: "scope-alpha"), + authority, + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("nyxid_api_key_list_unauthorized"); + schedules.BeginCredentialOperations.Should().ContainSingle(); + schedules.FailedCredentialOperations.Should().ContainSingle().Which.ErrorCode + .Should().Be("nyxid_api_key_list_unauthorized"); + schedules.RecordedCredentialCandidates.Should().BeEmpty(); + schedules.CompletedCredentialOperations.Should().BeEmpty(); + materializer.MaterializeCallCount.Should().Be(1); + } + [Fact] public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithoutMutation() { @@ -1823,6 +1902,10 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindin body.Should().Contain("\"status\":\"not_configured\""); schedules.LastScheduleGet.Should().StartWith("workflow-trigger-"); schedules.LastScheduleGet.Should().HaveLength("workflow-trigger-".Length + 32); + schedules.LastTeamAutomationGet.Should().BeEquivalentTo(new TeamMemberAutomationOwner( + "scope-alpha", + "wf-alpha", + "workflow-external-trigger")); } [Fact] @@ -1837,10 +1920,9 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredBinding() var schedules = new RecordingWorkflowScheduledDispatchService { Detail = new ScheduledDispatchDetail( - WorkflowScheduleSummary(triggerId) with + ExternalTriggerScheduleSummary(triggerId) with { ServiceRevisionId = "rev-alpha", - CredentialSourceKind = ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, PermissionDigest = "digest-alpha", PolicyVersion = "policy-alpha", }, @@ -1873,7 +1955,7 @@ public async Task WorkflowExternalTriggerFire_ShouldRejectMismatchedScopeWithout var http = CreateHttpContext("scope-beta"); var schedules = new RecordingWorkflowScheduledDispatchService { - Detail = new ScheduledDispatchDetail(WorkflowScheduleSummary(triggerId), []), + Detail = new ScheduledDispatchDetail(ExternalTriggerScheduleSummary(triggerId), []), }; var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( @@ -1892,13 +1974,13 @@ public async Task WorkflowExternalTriggerFire_ShouldRejectMismatchedScopeWithout } [Fact] - public async Task WorkflowExternalTriggerFire_ShouldPassExpectedWorkflowTarget() + public async Task WorkflowExternalTriggerFire_ShouldRunTeamAutomationWithExternalTriggerOwner() { var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); var http = CreateHttpContext("scope-alpha"); var schedules = new RecordingWorkflowScheduledDispatchService { - Detail = new ScheduledDispatchDetail(WorkflowScheduleSummary(triggerId), []), + Detail = new ScheduledDispatchDetail(ExternalTriggerScheduleSummary(triggerId), []), }; var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( @@ -1912,8 +1994,11 @@ public async Task WorkflowExternalTriggerFire_ShouldPassExpectedWorkflowTarget() http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); schedules.RunNowScheduleIds.Should().ContainSingle().Which.Should().Be(triggerId); - schedules.RunNowContexts.Should().ContainSingle().Which!.ExpectedServiceTarget!.ServiceIdentity.ServiceId - .Should().Be("svc-alpha"); + schedules.RunNowContexts.Should().BeEmpty(); + schedules.RunNowTeamOwners.Should().ContainSingle().Which.Should().BeEquivalentTo(new TeamMemberAutomationOwner( + "scope-alpha", + "wf-alpha", + "workflow-external-trigger")); } [Fact] @@ -2748,6 +2833,58 @@ private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleI ServiceRevisionId = "rev-alpha", }; + private static ScheduledDispatchSummary ExternalTriggerScheduleSummary(string triggerId) => + WorkflowScheduleSummary(triggerId) with + { + TeamOwned = true, + TeamOwnerScopeId = "scope-alpha", + TeamOwnerMemberId = "wf-alpha", + TeamId = "workflow-external-trigger", + CredentialSourceKind = ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, + TeamAutomationLifecycleStatus = TeamAutomationLifecycleStatus.Active, + }; + + private static StudioMemberAutomationHttpAuthority ExternalTriggerAuthority() => + new( + new AuthenticatedAuthorizationOwnerContext( + new AuthorizationOwnerIdentity + { + Authority = NyxIdAuthorizationAuthorities.NyxId, + OwnerKind = AuthorizationOwnerKind.Personal, + OwnerSubject = "nyx-owner-alpha", + }, + OwnerScope.NyxIdPlatform, + string.Empty, + "caller-alpha", + "binding-caller-alpha"), + "transient-provisioning-token"); + + private static ScheduledDispatchConfiguration ExternalTriggerConfiguration(string scheduleId) => + new( + scheduleId, + "External trigger", + new ScheduledDispatchTargetDescriptor( + ScheduledDispatchTargetKind.ServiceInvocation, + ServiceInvocation: new ScheduledServiceInvocationTargetDescriptor( + new ServiceIdentity + { + TenantId = "scope-alpha", + AppId = "workflow-app", + Namespace = "workflow-ns", + ServiceId = "svc-alpha", + }, + "chat", + Any.Pack(new ChatRequestEvent { Prompt = "run workflow" }), + "rev-alpha")), + "0 9 * * *", + "UTC", + true, + new Dictionary(StringComparer.Ordinal), + ScheduledDispatchScheduleKind.Workflow) + { + CredentialRequirementTargetKind = ScheduledDispatchCredentialRequirementTargetKind.WorkflowService, + }; + private sealed class RecordingScopeWorkflowQueryPort : IScopeWorkflowQueryPort, IScopeWorkflowCatalogueCommittedSourcePort @@ -2805,6 +2942,206 @@ public Task LookupCatalogueByWorkflowIdAsync } } + private sealed class RecordingWorkflowEvidenceQueryPort : IScheduledInvocationWorkflowEvidenceQueryPort + { + public Task GetAsync( + string scopeId, + string publishedServiceId, + string workflowRevisionId, + CancellationToken ct = default) => + Task.FromResult(new ScheduledInvocationWorkflowEvidence( + 5, + [new ExternalWorkflowCapabilityRef + { + NyxIdUserService = new NyxIdUserServiceCapabilityRef + { + UserServiceId = "nyx-service-alpha", + ServiceSlugSnapshot = "service-alpha", + }, + }], + OwnerLLMRouteRequired: false, + AuthorizationGrantRequirement.Required)); + } + + private sealed class RecordingExternalTriggerAuthorizationPlanner : IScheduledInvocationAuthorizationPlanner + { + public const string PermissionDigest = "permission-digest-alpha"; + public const string PolicyVersion = ScheduledInvocationAuthorizationContractVersions.CredentialPolicy; + + public Task PlanAsync( + ScheduledInvocationAuthorizationRequest request, + CancellationToken ct = default) => + Task.FromResult(ScheduledInvocationAuthorizationPlanResult.Succeeded(CreatePlan())); + + private static ScheduledInvocationAuthorizationPlan CreatePlan() + { + var plan = new ScheduledInvocationAuthorizationPlan + { + PermissionDigest = PermissionDigest, + Owner = new AuthorizationOwnerIdentity + { + Authority = NyxIdAuthorizationAuthorities.NyxId, + OwnerKind = AuthorizationOwnerKind.Personal, + OwnerSubject = "nyx-owner-alpha", + }, + CredentialPolicy = new ScheduledInvocationCredentialPolicy + { + ServiceGrantRequirement = AuthorizationGrantRequirement.Required, + NodeGrantRequirement = AuthorizationGrantRequirement.Required, + ExpiresAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(24)), + PolicyVersion = PolicyVersion, + }, + CatalogAuthority = new NyxIdCatalogAuthorityStamp + { + ActorStateVersion = 13, + ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddMinutes(-10)), + FreshUntil = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(1)), + ContentDigest = "catalog-digest-alpha", + ContractVersion = "scope-plan-contract/v1", + PolicyVersion = "scope-plan-policy/v1", + EvaluatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddMinutes(-10)), + }, + }; + plan.CredentialPolicy.Scopes.Add(new[] { NyxIdCredentialScope.Read, NyxIdCredentialScope.Proxy }); + plan.NyxIdServiceGrants.Add(new NyxIdServiceGrant + { + UserServiceId = "nyx-service-alpha", + NodeGrantRequirement = AuthorizationGrantRequirement.Required, + NodeIds = { "nyx-node-alpha" }, + }); + plan.Disclosures.Add(new[] + { + ScheduledInvocationDisclosure.DedicatedCredential, + ScheduledInvocationDisclosure.AevatarSecretCustody, + ScheduledInvocationDisclosure.BrowserNeverReceivesSecret, + ScheduledInvocationDisclosure.DeleteRevokesCredential, + ScheduledInvocationDisclosure.PauseResumePreservesCredential, + }); + plan.SourceStamps.Add(new[] + { + new AuthorizationSourceStamp + { + SourceKind = AuthorizationSourceKind.WorkflowRevision, + SourceId = "rev-alpha", + StateVersion = 5, + }, + new AuthorizationSourceStamp + { + SourceKind = AuthorizationSourceKind.ConnectorCatalog, + SourceId = "connector-alpha", + StateVersion = 7, + }, + }); + return plan; + } + } + + private sealed class RecordingExternalTriggerAuthorizationRevalidator( + IScheduledInvocationAuthorizationPlanner planner) : IScheduledInvocationAuthorizationRevalidator + { + public async Task RevalidateAsync( + ScheduledInvocationAuthorizationRequest request, + ScheduledInvocationAuthorizationConfirmation confirmation, + CancellationToken ct = default) + { + var result = await planner.PlanAsync(request, ct); + if (!result.Success) + { + return ScheduledInvocationAuthorizationValidationResult.Failed( + ScheduledInvocationAuthorizationFailureCode.AuthorizationPlanChanged, + result.Detail); + } + + return SuccessfulValidation(result.Plan!); + } + + private static ScheduledInvocationAuthorizationValidationResult SuccessfulValidation( + ScheduledInvocationAuthorizationPlan plan) + { + var constructor = typeof(ValidatedScheduledInvocationAuthorizationPlan) + .GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + [typeof(ScheduledInvocationAuthorizationPlan)], + modifiers: null) ?? throw new InvalidOperationException("validated_plan_constructor_missing"); + var validatedPlan = (ValidatedScheduledInvocationAuthorizationPlan)constructor.Invoke([plan]); + return new ScheduledInvocationAuthorizationValidationResult( + validatedPlan, + ScheduledInvocationAuthorizationFailureCode.Unspecified, + string.Empty, + ObservedCatalogStateVersion: plan.CatalogAuthority?.ActorStateVersion ?? 0); + } + } + + private sealed class RecordingExternalTriggerCredentialMaterializer( + Exception? exception = null) : IStudioScheduledCredentialMaterializer + { + public int MaterializeCallCount { get; private set; } + public string? BearerToken { get; private set; } + + public ScheduledCredentialEffectLocator CreateEffectLocator( + string scheduleId, + string operationId, + ScheduledInvocationAuthorizationOwner credentialOwner) => + new( + $"credential-{scheduleId}-{operationId}", + $"secret-{scheduleId}-{operationId}", + CredentialSecretPurposes.ScheduledInvocationAgentKey, + $"schedule:{scheduleId}", + credentialOwner); + + public Task MaterializeAsync( + string bearerToken, + ValidatedScheduledInvocationAuthorizationPlan validatedPlan, + string scheduleId, + string operationId, + ScheduledCredentialEffectLocator effectLocator, + StudioScheduledCredentialMaterializationMode mode, + Aevatar.Foundation.Abstractions.OwnerScope ownerScope, + CancellationToken ct = default) + { + MaterializeCallCount++; + BearerToken = bearerToken; + if (exception != null) + return Task.FromException(exception); + + var expiresAt = validatedPlan.Plan.CredentialPolicy.ExpiresAt.ToDateTimeOffset().AddMinutes(-1); + return Task.FromResult(new StudioScheduledCredential( + "agent-key-alpha", + new SecretReference + { + Ref = "secret-alpha", + Purpose = CredentialSecretPurposes.ScheduledInvocationAgentKey, + OwnerScopeKey = "schedule:test", + ExpiresAtUnixMs = expiresAt.ToUnixTimeMilliseconds(), + }, + expiresAt, + new ScheduledInvocationAuthorizationOwner("nyxid", "Personal", "nyx-owner-alpha"))); + } + + public Task RevokeAsync( + string bearerToken, + AuthenticatedAuthorizationOwnerContext authenticatedOwner, + StudioScheduledCredential credential, + bool revokeNyxId, + bool revokeVault, + CancellationToken ct = default) => + Task.FromResult(new StudioScheduledCredentialRevocationResult(true, true, string.Empty)); + } + + private sealed class RecordingWorkflowCallerAccessTokenProvider : IWorkflowCallerAccessTokenProvider + { + public List Authorities { get; } = []; + + public Task IssueAsync( + CredentialWorkflowCallerAuthority authority, + CancellationToken ct = default) + { + Authorities.Add(authority.Clone()); + return Task.FromResult("issued-provisioning-token"); + } + } + private sealed class RecordingWorkflowExternalTriggerProvisioningPort : IWorkflowExternalTriggerProvisioningPort { public List Workflows { get; } = []; @@ -2867,7 +3204,93 @@ private sealed class RecordingWorkflowScheduledDispatchService : IScheduledDispa public (string CronExpression, string? Timezone, int Count, DateTimeOffset? FromUtc)? LastPreview { get; private set; } public ArgumentException? PreviewError { get; init; } public List RunNowScheduleIds { get; } = []; + public List RunNowTeamOwners { get; } = []; + public TeamMemberAutomationOwner? LastTeamAutomationGet { get; private set; } + public (string ScheduleId, string ScopeId, string? TeamId, string? MemberId)? LastTeamScheduleGet { get; private set; } public ScheduledDispatchDetail? Detail { get; init; } + public List BeginCredentialOperations { get; } = []; + public List<(string ScheduleId, string OperationId, string ErrorCode)> FailedCredentialOperations { get; } = []; + public List<(string ScheduleId, string OperationId)> RecordedCredentialCandidates { get; } = []; + public List<(string ScheduleId, string OperationId)> CompletedCredentialOperations { get; } = []; + + public Task BeginTeamAutomationCredentialOperationAsync( + TeamAutomationCredentialOperation operation, + CancellationToken ct = default) + { + BeginCredentialOperations.Add(operation); + return Task.FromResult(Committed( + operation.ScheduleId, + operation.OperationId, + operation.IdempotencyKey, + TeamAutomationOperationObservationStages.Begin, + ownsEffectAttempt: true, + "cmd-team-begin", + effectAttemptId: "attempt-alpha", + credentialEffectLocator: operation.CredentialEffectLocator, + newOperationCommitted: true)); + } + + public Task RecordTeamAutomationCredentialCandidateAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + string operationId, + string idempotencyKey, + string effectAttemptId, + ScheduledInvocationAgentKeyCredentialReference credential, + ScheduledInvocationAuthorizationOwner credentialOwner, + CancellationToken ct = default) + { + RecordedCredentialCandidates.Add((scheduleId, operationId)); + return Task.FromResult(Committed( + scheduleId, + operationId, + idempotencyKey, + TeamAutomationOperationObservationStages.Candidate, + ownsEffectAttempt: false, + "cmd-team-candidate", + candidateCredential: credential, + candidateOwner: credentialOwner)); + } + + public Task CompleteTeamAutomationCredentialOperationAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + string operationId, + string idempotencyKey, + string effectAttemptId, + ScheduledInvocationAgentKeyCredentialReference credential, + ScheduledDispatchConfiguration configuration, + CancellationToken ct = default) + { + CompletedCredentialOperations.Add((scheduleId, operationId)); + return Task.FromResult(Committed( + scheduleId, + operationId, + idempotencyKey, + TeamAutomationOperationObservationStages.Complete, + ownsEffectAttempt: false, + "cmd-team-complete")); + } + + public Task FailTeamAutomationCredentialOperationAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + string operationId, + string idempotencyKey, + string effectAttemptId, + string errorCode, + CancellationToken ct = default) + { + FailedCredentialOperations.Add((scheduleId, operationId, errorCode)); + return Task.FromResult(Committed( + scheduleId, + operationId, + idempotencyKey, + TeamAutomationOperationObservationStages.Fail, + ownsEffectAttempt: false, + "cmd-team-fail", + errorCode)); + } public Task CreateAsync( ScheduledDispatchConfiguration configuration, @@ -2940,6 +3363,39 @@ public Task DeleteAsync( return Task.FromResult(Detail?.Schedule.ScheduleId == scheduleId ? Detail : null); } + public Task GetTeamAutomationAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + CancellationToken ct = default) + { + LastScheduleGet = scheduleId; + LastTeamAutomationGet = owner; + return Task.FromResult(IsTeamSchedule(scheduleId, owner) ? Detail : null); + } + + public Task GetTeamScheduleAsync( + string scheduleId, + string scopeId, + string? teamId = null, + string? memberId = null, + CancellationToken ct = default) + { + LastTeamScheduleGet = (scheduleId, scopeId, teamId, memberId); + if (Detail?.Schedule is not { } schedule || schedule.ScheduleId != scheduleId) + return Task.FromResult(null); + + if (!schedule.TeamOwned || !string.Equals(schedule.TeamOwnerScopeId, scopeId, StringComparison.Ordinal)) + return Task.FromResult(null); + + if (!string.IsNullOrWhiteSpace(teamId) && !string.Equals(schedule.TeamId, teamId, StringComparison.Ordinal)) + return Task.FromResult(null); + + if (!string.IsNullOrWhiteSpace(memberId) && !string.Equals(schedule.TeamOwnerMemberId, memberId, StringComparison.Ordinal)) + return Task.FromResult(null); + + return Task.FromResult(Detail); + } + public Task ListAsync( int take = 50, string? cursor = null, @@ -2976,7 +3432,29 @@ public Task RunNowAsync( { RunNowScheduleIds.Add(scheduleId); RunNowContexts.Add(context); - return Task.FromResult(new ScheduledDispatchRunNowReceipt( + return Task.FromResult(RunNowReceipt(scheduleId)); + } + + public Task RunTeamAutomationNowAsync( + string scheduleId, + TeamMemberAutomationOwner owner, + CancellationToken ct = default) + { + RunNowScheduleIds.Add(scheduleId); + RunNowTeamOwners.Add(owner); + return Task.FromResult(RunNowReceipt(scheduleId)); + } + + private bool IsTeamSchedule(string scheduleId, TeamMemberAutomationOwner owner) => + Detail?.Schedule is { } schedule && + schedule.ScheduleId == scheduleId && + schedule.TeamOwned && + string.Equals(schedule.TeamOwnerScopeId, owner.ScopeId, StringComparison.Ordinal) && + string.Equals(schedule.TeamOwnerMemberId, owner.MemberId, StringComparison.Ordinal) && + string.Equals(schedule.TeamId, owner.TeamId, StringComparison.Ordinal); + + private static ScheduledDispatchRunNowReceipt RunNowReceipt(string scheduleId) => + new( scheduleId, $"actor:{scheduleId}", DateTimeOffset.UtcNow, @@ -2985,8 +3463,44 @@ public Task RunNowAsync( "cmd-run-now", "corr-run-now", DateTimeOffset.UtcNow, - "accepted")); - } + "accepted"); + + private static TeamAutomationCommittedMutationReceipt Committed( + string scheduleId, + string operationId, + string idempotencyKey, + string stage, + bool ownsEffectAttempt, + string commandId, + string errorCode = "", + string effectAttemptId = "", + ScheduledInvocationAgentKeyCredentialReference? candidateCredential = null, + ScheduledInvocationAuthorizationOwner? candidateOwner = null, + ScheduledCredentialEffectLocator? credentialEffectLocator = null, + bool newOperationCommitted = false) => + new( + MutationReceipt(scheduleId) with { CommandId = commandId }, + new TeamAutomationOperationCommittedOutcome( + scheduleId, + operationId, + idempotencyKey, + stage, + ownsEffectAttempt, + StateVersion: 1, + errorCode, + ErrorMessage: string.Empty, + ObservedAtUtc: DateTimeOffset.UtcNow, + PendingRevocationCredential: null, + PendingRevocationOwner: null, + NyxIdRevocationPending: false, + VaultRevocationPending: false, + EffectAttemptId: effectAttemptId, + EffectAttemptGeneration: ownsEffectAttempt ? 1 : 0, + EffectAttemptExpiresAtUtc: ownsEffectAttempt ? DateTimeOffset.UtcNow.AddMinutes(5) : null, + CandidateCredential: candidateCredential, + CandidateOwner: candidateOwner, + CredentialEffectLocator: credentialEffectLocator, + NewOperationCommitted: newOperationCommitted)); private static ScheduledDispatchMutationReceipt MutationReceipt(string scheduleId) => new( scheduleId, From 88946f48d4d814d7693fcd8b338506b60ff171dd Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 9 Oct 2026 11:12:11 +0800 Subject: [PATCH 8/9] Refresh NyxID conformance guard pins. Co-Authored-By: Claude Opus 4.6 --- .../contracts/nyxid-assistant-conformance/v1/sources.json | 8 ++++---- .../AgentRunReplyGenerationExecutorTests.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 2b2a1f5368..bed374e9fd 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,8 +2,8 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "d0d49a256ed15d24457649703b97618b6b64891c", - "contract_files_sha256": "09cf89aedd527a9a34a950708f13bfd4f1f40bd51ad200f92e40c5065b211720", + "revision": "20d1cd104ce24fc6942dfaf4bc23b6bc40ad5432", + "contract_files_sha256": "7668e545af09bab23c99f9f698f9cbae032ec7d6ddee56dd5b5551cfb6b3674f", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", @@ -12,12 +12,12 @@ "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_recovery_secret.proto": "07dbc449a732df6c7a6d0a97054ddbe35a517f4af4c5670b05ed0bea0bf2011a", "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "5d698e6d75b90605eb40092899a775ecd45a455aaa17656ccebaf247fc71d5c4", "docs/adr/0048-nyxid-assistant-operation-class-boundary.md": "884aca09774e773e68154c923fec8078610b2cf8e97f581fedc36e10451ccec3", - "src/Aevatar.AI.Abstractions/ai_messages.proto": "50e334e9fdbc1c11e0f70345095de2c7d9f3ea5f4b9f84a30f729377621576e2", + "src/Aevatar.AI.Abstractions/ai_messages.proto": "6831b61c1016d1584654c0edf61788e9c448a7260449936e0e121fdfa789475e", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiAccessContracts.cs": "a2e526a0a227f868304f122e9a65796164089fa69b0c27926f4c27c78b3ab0de", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "16b25f5bdd5004bc0c5402ae0adf2d294c270ae83135c95f0f342089ea80da05", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", - "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "88cf9ee20ea024c84768316850f92262003b7a55a2a853066445257bdd7a5d87" + "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "8baedc2ee9ea022c0a8da8c5b2e0c9f50dcaaf983ebc12f288a335a6213a793f" } }, "nyxid": { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunReplyGenerationExecutorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunReplyGenerationExecutorTests.cs index 28e82af8b1..dd7b564493 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunReplyGenerationExecutorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunReplyGenerationExecutorTests.cs @@ -520,7 +520,7 @@ await fixture.Executor.BuildLlmStepExecutionAsync( } [Fact] - public async Task BuildInitialStepState_WhenRegistrationAgentKeyCannotResolve_ShouldPreserveFailClosedRegistrationAuthority() + public async Task BuildInitialStepState_WhenRegistrationAgentKeyCannotResolve_ShouldClearAllNyxIdCredentials() { var fixture = CreateProfiledChannelExecutor(); var request = fixture.Request.Clone(); From a560e4966d7f67415bb6b2f99132c2265a8ac48e Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Fri, 9 Oct 2026 14:07:25 +0800 Subject: [PATCH 9/9] Use webhook bindings for external triggers. Co-Authored-By: Claude Opus 4.6 --- .../ServiceCollectionExtensions.cs | 2 - ...kflowExternalTriggerProvisioningService.cs | 900 ------------------ .../ScopeWorkflowScheduleEndpoints.cs | 480 +++------- .../WorkflowWebhookAgentKeyMaterializer.cs | 4 +- .../WorkflowWebhookBindingEndpoints.cs | 6 +- .../ScopeWorkflowEndpointsTests.cs | 784 ++++++--------- 6 files changed, 472 insertions(+), 1704 deletions(-) delete mode 100644 src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs diff --git a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs index 07452df00d..742645f28e 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs @@ -209,7 +209,6 @@ public static IServiceCollection AddGAgentServiceCapability( services.AddScheduledCredentialAdmissionPort(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddTransient(); services.TryAddSingleton, StaticGAgentStreamInvocationApplicationService>(); @@ -294,7 +293,6 @@ public static IServiceCollection AddScheduledDispatchCapability( services.AddScheduledCredentialAdmissionPort(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddTransient(); return services; diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs deleted file mode 100644 index 50e9f475c2..0000000000 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/Schedules/WorkflowExternalTriggerProvisioningService.cs +++ /dev/null @@ -1,900 +0,0 @@ -using System.Buffers.Binary; -using System.Security.Cryptography; -using System.Text; -using Aevatar.AI.Abstractions; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Credentials; -using Aevatar.GAgentService.Abstractions; -using Aevatar.GAgentService.Abstractions.Ports; -using Aevatar.GAgentService.Abstractions.Schedules; -using Aevatar.GAgentService.Abstractions.Schedules.Authorization; -using Aevatar.Studio.Application.Provisioning; -using Aevatar.Workflow.Abstractions; -using Aevatar.Workflow.Abstractions.Credentials; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.GAgentService.Hosting.Endpoints.Schedules; - -internal interface IWorkflowExternalTriggerProvisioningPort -{ - Task ProvisionAsync( - ScopeWorkflowSummary workflow, - ScheduledDispatchConfiguration configuration, - ScheduledDispatchMutationContext context, - StudioMemberAutomationHttpAuthority authority, - CancellationToken ct = default); -} - -internal sealed record WorkflowExternalTriggerProvisioningResult( - ScheduledDispatchMutationReceipt Receipt, - ScheduledDispatchConfiguration Configuration, - ScheduledDispatchCredentialSourceKind CredentialSourceKind, - DateTimeOffset? CredentialExpiresAt, - string PermissionDigest, - string PolicyVersion); - -internal sealed class WorkflowExternalTriggerProvisioningService : IWorkflowExternalTriggerProvisioningPort -{ - private const string WorkflowInvokeEndpointId = "chat"; - private const string ProvisioningBearerCapabilityScope = "proxy"; - private const string ExternalTriggerTeamId = "workflow-external-trigger"; - - private readonly IScheduledDispatchApplicationService _scheduleService; - private readonly IScheduledInvocationAuthorizationPlanner _authorizationPlanner; - private readonly IScheduledInvocationAuthorizationRevalidator _authorizationRevalidator; - private readonly IScheduledInvocationWorkflowEvidenceQueryPort? _workflowEvidenceQueryPort; - private readonly IStudioScheduledCredentialMaterializer? _credentialMaterializer; - private readonly IWorkflowCallerAccessTokenProvider? _callerAccessTokenProvider; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public WorkflowExternalTriggerProvisioningService( - IScheduledDispatchApplicationService scheduleService, - IScheduledInvocationAuthorizationPlanner authorizationPlanner, - IScheduledInvocationAuthorizationRevalidator authorizationRevalidator, - TimeProvider timeProvider, - ILogger? logger = null, - IStudioScheduledCredentialMaterializer? credentialMaterializer = null, - IScheduledInvocationWorkflowEvidenceQueryPort? workflowEvidenceQueryPort = null, - IWorkflowCallerAccessTokenProvider? callerAccessTokenProvider = null) - { - _scheduleService = scheduleService ?? throw new ArgumentNullException(nameof(scheduleService)); - _authorizationPlanner = authorizationPlanner ?? throw new ArgumentNullException(nameof(authorizationPlanner)); - _authorizationRevalidator = authorizationRevalidator ?? throw new ArgumentNullException(nameof(authorizationRevalidator)); - _workflowEvidenceQueryPort = workflowEvidenceQueryPort; - _credentialMaterializer = credentialMaterializer; - _callerAccessTokenProvider = callerAccessTokenProvider; - _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - _logger = logger ?? NullLogger.Instance; - } - - public async Task ProvisionAsync( - ScopeWorkflowSummary workflow, - ScheduledDispatchConfiguration configuration, - ScheduledDispatchMutationContext context, - StudioMemberAutomationHttpAuthority authority, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(workflow); - ArgumentNullException.ThrowIfNull(configuration); - ArgumentNullException.ThrowIfNull(authority); - - var scheduleId = NormalizeRequired(configuration.ScheduleId, nameof(configuration.ScheduleId)); - var serviceInvocation = configuration.Target.ServiceInvocation - ?? throw new ArgumentException("External trigger target must be a service invocation.", nameof(configuration)); - if (_workflowEvidenceQueryPort == null) - throw new InvalidOperationException("workflow_authorization_evidence_query_not_configured"); - - var workflowEvidence = await _workflowEvidenceQueryPort.GetAsync( - workflow.ScopeId, - workflow.PublishedServiceId, - workflow.ActiveRevisionId, - ct); - if (workflowEvidence == null) - throw new InvalidOperationException("workflow_authorization_evidence_not_found"); - - var authorizationRequest = BuildAuthorizationRequest( - workflow, - workflowEvidence, - authority.AuthenticatedOwner); - var firstPlan = await _authorizationPlanner.PlanAsync(authorizationRequest, ct); - if (!firstPlan.Success) - throw new InvalidOperationException(firstPlan.Detail); - - var plan = firstPlan.Plan!; - EnsureRequiredDisclosures(plan); - var validation = await _authorizationRevalidator.RevalidateAsync( - authorizationRequest, - BuildConfirmation(authorizationRequest, plan.PermissionDigest, plan.CredentialPolicy.PolicyVersion), - ct); - if (!validation.Success) - throw new InvalidOperationException(validation.Detail); - - if (_credentialMaterializer == null) - throw new InvalidOperationException("workflow_external_trigger_credential_materializer_not_configured"); - - var validatedPlan = validation.ValidatedPlan!; - var authorizationFact = ToScheduleAuthorizationFact(validatedPlan.Plan); - var callerAuthority = BuildScheduleCallerAuthority(authority.AuthenticatedOwner); - var provisioningBearerToken = await ResolveProvisioningBearerTokenAsync(authority, ct); - var owner = BuildOwner(workflow); - var existing = await _scheduleService.GetAsync(scheduleId, ct); - var operationKind = existing?.Schedule.CredentialSourceKind == - ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey - ? TeamAutomationOperationKind.Reauthorize - : TeamAutomationOperationKind.Create; - var operationId = BuildOperationIdentity( - operationKind, - scheduleId, - plan.PermissionDigest); - var effectLocator = _credentialMaterializer.CreateEffectLocator( - scheduleId, - operationId, - ToAuthorizationOwner(authority.AuthenticatedOwner)); - var activationDecision = BuildActivationDecision( - configuration, - serviceInvocation, - owner, - callerAuthority, - authorizationFact); - var mutationDigest = BuildTeamAutomationMutationDigest(activationDecision); - var idempotencyKey = mutationDigest; - - var began = await _scheduleService.BeginTeamAutomationCredentialOperationAsync( - new TeamAutomationCredentialOperation( - scheduleId, - owner, - operationId, - idempotencyKey, - plan.PermissionDigest, - plan.CredentialPolicy.PolicyVersion, - operationKind, - effectLocator, - activationDecision, - mutationDigest), - ct); - if (!began.Admission.Accepted) - throw new InvalidOperationException("workflow_external_trigger_credential_begin_rejected"); - if (!began.Outcome.OwnsEffectAttempt) - { - return new WorkflowExternalTriggerProvisioningResult( - began.Admission, - configuration, - ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - null, - plan.PermissionDigest, - plan.CredentialPolicy.PolicyVersion); - } - - var effectAttemptId = NormalizeRequired( - began.Outcome.EffectAttemptId, - nameof(began.Outcome.EffectAttemptId)); - var committedEffectLocator = began.Outcome.CredentialEffectLocator - ?? throw new InvalidOperationException("team_automation_credential_effect_locator_missing"); - if (committedEffectLocator != effectLocator) - throw new InvalidOperationException("team_automation_credential_effect_locator_conflict"); - - StudioScheduledCredential? credential = null; - var candidateCommitted = began.Outcome.CandidateCredential != null; - var candidateCommitAttempted = candidateCommitted; - var activationAttempted = false; - try - { - credential = candidateCommitted - ? ToStudioScheduledCredential( - began.Outcome.CandidateCredential!, - began.Outcome.CandidateOwner) - : await _credentialMaterializer.MaterializeAsync( - provisioningBearerToken, - validatedPlan, - scheduleId, - operationId, - effectLocator, - began.Outcome.NewOperationCommitted - ? StudioScheduledCredentialMaterializationMode.Initial - : StudioScheduledCredentialMaterializationMode.Recovery, - BuildOwnerScope(authority.AuthenticatedOwner), - ct); - EnsureCredentialMatchesPlan(credential, plan, _timeProvider.GetUtcNow()); - if (!candidateCommitted) - { - candidateCommitAttempted = true; - var candidate = await _scheduleService.RecordTeamAutomationCredentialCandidateAsync( - scheduleId, - owner, - operationId, - idempotencyKey, - effectAttemptId, - BuildScheduleCredential(credential), - credential.Owner, - ct); - if (!candidate.Admission.Accepted) - throw new InvalidOperationException("team_automation_candidate_rejected"); - candidateCommitted = true; - } - - var activatedConfiguration = configuration with - { - Target = configuration.Target with - { - ServiceInvocation = serviceInvocation with - { - Auth = BuildScheduleAuth(credential, callerAuthority), - AuthorizationFact = CloneScheduleAuthorizationFact(authorizationFact), - }, - }, - TeamAutomationOwner = owner, - }; - activationAttempted = true; - var activation = await _scheduleService.CompleteTeamAutomationCredentialOperationAsync( - scheduleId, - owner, - operationId, - idempotencyKey, - effectAttemptId, - BuildScheduleCredential(credential), - activatedConfiguration, - ct); - if (!activation.Admission.Accepted) - throw new InvalidOperationException("team_automation_activation_rejected"); - _ = await ExecutePendingRevocationAsync( - activation.Outcome, - provisioningBearerToken, - authority.AuthenticatedOwner, - owner, - CancellationToken.None); - return new WorkflowExternalTriggerProvisioningResult( - activation.Admission, - activatedConfiguration, - ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - credential.ExpiresAtUtc, - plan.PermissionDigest, - plan.CredentialPolicy.PolicyVersion); - } - catch (Exception ex) - { - if (!activationAttempted) - { - _ = await TryRecordFailureAsync( - scheduleId, - owner, - operationId, - idempotencyKey, - effectAttemptId, - ToStableFailureCode(ex), - CancellationToken.None); - } - - if (!candidateCommitAttempted && credential != null) - { - try - { - _ = await _credentialMaterializer.RevokeAsync( - provisioningBearerToken, - authority.AuthenticatedOwner, - credential, - revokeNyxId: true, - revokeVault: true, - CancellationToken.None); - } - catch (Exception revokeEx) - { - _logger.LogWarning( - revokeEx, - "Failed to revoke external trigger credential after provisioning failure for schedule {ScheduleId}.", - scheduleId); - } - } - - throw; - } - } - - private ScheduledInvocationAuthorizationRequest BuildAuthorizationRequest( - ScopeWorkflowSummary workflow, - ScheduledInvocationWorkflowEvidence evidence, - AuthenticatedAuthorizationOwnerContext authenticatedOwner) - { - var capabilities = ResolveWorkflowCapabilities(evidence.ExternalCapabilities); - var evaluatedAtUtc = _timeProvider.GetUtcNow(); - return new ScheduledInvocationAuthorizationRequest( - new ScheduledInvocationTarget - { - ScheduledAgent = new ScheduledAgentInvocationTarget - { - RegistrationScopeId = workflow.ScopeId, - ExecutionScopeId = workflow.ScopeId, - ScheduledAgentId = workflow.WorkflowId, - }, - }, - authenticatedOwner, - capabilities, - evidence.ServiceGrantRequirement, - evaluatedAtUtc.AddDays(30), - evaluatedAtUtc, - [new AuthorizationSourceStamp - { - SourceKind = AuthorizationSourceKind.WorkflowRevision, - SourceId = workflow.ActiveRevisionId, - StateVersion = evidence.StateVersion, - }]); - } - - private static IReadOnlyList ResolveWorkflowCapabilities( - IEnumerable capabilities) - { - var services = new SortedDictionary(StringComparer.Ordinal); - foreach (var capability in capabilities) - { - NyxIdUserServiceCapabilityRef? service = capability.CapabilityCase switch - { - ExternalWorkflowCapabilityRef.CapabilityOneofCase.NyxIdUserService => - capability.NyxIdUserService.Clone(), - ExternalWorkflowCapabilityRef.CapabilityOneofCase.NyxIdUserRequest => - new NyxIdUserServiceCapabilityRef - { - UserServiceId = capability.NyxIdUserRequest.Request?.UserServiceId ?? string.Empty, - ServiceSlugSnapshot = capability.NyxIdUserRequest.ServiceSlugSnapshot, - }, - ExternalWorkflowCapabilityRef.CapabilityOneofCase.CodeExecution => - new NyxIdUserServiceCapabilityRef - { - UserServiceId = capability.CodeExecution.UserServiceId, - ServiceSlugSnapshot = capability.CodeExecution.ServiceSlugSnapshot, - }, - _ => null, - }; - if (service == null) - continue; - var userServiceId = NormalizeRequired(service.UserServiceId, nameof(service.UserServiceId)); - if (!services.TryGetValue(userServiceId, out var existing)) - { - services[userServiceId] = service; - continue; - } - if (existing.ServiceSlugSnapshot.Length == 0 && service.ServiceSlugSnapshot.Length > 0) - existing.ServiceSlugSnapshot = service.ServiceSlugSnapshot; - } - return services.Values.ToArray(); - } - - private static TeamMemberAutomationOwner BuildOwner(ScopeWorkflowSummary workflow) => - new( - NormalizeRequired(workflow.ScopeId, nameof(workflow.ScopeId)), - NormalizeRequired(workflow.WorkflowId, nameof(workflow.WorkflowId)), - ExternalTriggerTeamId); - - private static ScheduledCallerNyxIdAuthority BuildScheduleCallerAuthority( - AuthenticatedAuthorizationOwnerContext owner) - { - var bindingId = NormalizeRequired(owner.VerifiedBindingId, nameof(owner.VerifiedBindingId)); - return new ScheduledCallerNyxIdAuthority - { - Platform = NormalizeRequired(owner.SubjectPlatform, nameof(owner.SubjectPlatform)), - Tenant = NormalizeOptional(owner.SubjectTenant) ?? string.Empty, - ExternalUserId = NormalizeRequired(owner.SubjectExternalUserId, nameof(owner.SubjectExternalUserId)), - Scope = ProvisioningBearerCapabilityScope, - BindingId = bindingId, - }; - } - - private static TeamAutomationActivationDecision BuildActivationDecision( - ScheduledDispatchConfiguration configuration, - ScheduledServiceInvocationTargetDescriptor serviceInvocation, - TeamMemberAutomationOwner owner, - ScheduledCallerNyxIdAuthority callerAuthority, - ScheduledInvocationAuthorizationFact authorizationFact) => - new( - configuration.ScheduleId, - configuration.DisplayName, - owner, - serviceInvocation.Identity, - serviceInvocation.EndpointId, - serviceInvocation.Payload.Clone(), - callerAuthority.Clone(), - CloneScheduleAuthorizationFact(authorizationFact), - configuration.CronExpression, - configuration.Timezone, - configuration.Enabled, - configuration.ScheduleKind, - configuration.Headers, - configuration.ScheduleMode, - configuration.OneShotFireAt, - configuration.CredentialRequirementTargetKind, - serviceInvocation.RevisionId ?? string.Empty, - serviceInvocation.Caller); - - private static ScheduledInvocationAuthorizationConfirmation BuildConfirmation( - ScheduledInvocationAuthorizationRequest request, - string permissionDigest, - string policyVersion) => - new() - { - InvocationTarget = request.InvocationTarget.Clone(), - Owner = request.Owner.Clone(), - SchemaVersion = ScheduledInvocationAuthorizationContractVersions.Schema, - PolicyVersion = NormalizeRequired(policyVersion, nameof(policyVersion)), - PermissionDigest = NormalizeRequired(permissionDigest, nameof(permissionDigest)), - }; - - private static ScheduledServiceInvocationAuth BuildScheduleAuth( - StudioScheduledCredential credential, - ScheduledCallerNyxIdAuthority callerAuthority) => - new(BuildScheduleCredential(credential)) - { - CallerAuthority = callerAuthority.Clone(), - }; - - private static ScheduledInvocationAgentKeyCredentialReference BuildScheduleCredential( - StudioScheduledCredential credential) => - new( - credential.SecretReference.Clone(), - credential.ApiKeyId, - credential.ExpiresAtUtc.ToUnixTimeMilliseconds(), - credential.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); - - private static StudioScheduledCredential ToStudioScheduledCredential( - ScheduledInvocationAgentKeyCredentialReference credential, - ScheduledInvocationAuthorizationOwner? owner) - { - ArgumentNullException.ThrowIfNull(credential); - return new StudioScheduledCredential( - NormalizeRequired(credential.ApiKeyId, nameof(credential.ApiKeyId)), - credential.SecretReference?.Clone() ?? throw new InvalidOperationException("revocation_descriptor_missing"), - DateTimeOffset.FromUnixTimeMilliseconds(credential.KeyExpiresAtUnixMs), - owner ?? throw new InvalidOperationException("credential_owner_missing"), - credential.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); - } - - private async Task ExecutePendingRevocationAsync( - TeamAutomationOperationCommittedOutcome outcome, - string bearerToken, - AuthenticatedAuthorizationOwnerContext authenticatedOwner, - TeamMemberAutomationOwner owner, - CancellationToken ct) - { - if (!outcome.NyxIdRevocationPending && !outcome.VaultRevocationPending) - return true; - if (!outcome.OwnsEffectAttempt) - return false; - - var result = outcome.PendingRevocationCredential == null || outcome.PendingRevocationOwner == null - ? new StudioScheduledCredentialRevocationResult( - !outcome.NyxIdRevocationPending, - !outcome.VaultRevocationPending, - "revocation_descriptor_missing") - : await RevokePendingCredentialAsync( - bearerToken, - authenticatedOwner, - outcome, - ct); - var completion = await _scheduleService.CompleteTeamAutomationRevocationAsync( - outcome.ScheduleId, - owner, - outcome.OperationId, - outcome.IdempotencyKey, - NormalizeRequired(outcome.EffectAttemptId, nameof(outcome.EffectAttemptId)), - result.NyxIdRevoked, - result.VaultRevoked, - result.ErrorCode, - ct); - return completion.Admission.Accepted && result.NyxIdRevoked && result.VaultRevoked; - } - - private async Task ResolveProvisioningBearerTokenAsync( - StudioMemberAutomationHttpAuthority authority, - CancellationToken ct) - { - if (_callerAccessTokenProvider != null) - { - var callerAuthority = BuildWorkflowCallerAuthority(authority.AuthenticatedOwner); - var issued = await _callerAccessTokenProvider.IssueAsync(callerAuthority, ct); - var issuedToken = WorkflowCallerCredentialTokens.ParseOptional(issued); - return issuedToken.IsValid - ? issuedToken.NormalizedBearerToken! - : throw new InvalidOperationException("workflow_caller_access_token_provider_returned_invalid_token"); - } - - var parsed = WorkflowCallerCredentialTokens.ParseOptional(authority.ProvisioningBearerToken); - return parsed.IsValid - ? parsed.NormalizedBearerToken! - : throw new UnauthorizedAccessException("provisioning_bearer_invalid"); - } - - private static WorkflowCallerNyxIdAuthority BuildWorkflowCallerAuthority( - AuthenticatedAuthorizationOwnerContext owner) - { - var subjectPlatform = NormalizeRequired(owner.SubjectPlatform, nameof(owner.SubjectPlatform)); - var subjectExternalUserId = NormalizeRequired(owner.SubjectExternalUserId, nameof(owner.SubjectExternalUserId)); - var bindingId = NormalizeRequired(owner.VerifiedBindingId, nameof(owner.VerifiedBindingId)); - return new WorkflowCallerNyxIdAuthority - { - Platform = subjectPlatform, - Tenant = NormalizeOptional(owner.SubjectTenant) ?? string.Empty, - ExternalUserId = subjectExternalUserId, - Scope = ProvisioningBearerCapabilityScope, - BindingId = bindingId, - }; - } - - private async Task RevokePendingCredentialAsync( - string bearerToken, - AuthenticatedAuthorizationOwnerContext authenticatedOwner, - TeamAutomationOperationCommittedOutcome outcome, - CancellationToken ct) - { - var pending = outcome.PendingRevocationCredential!; - var credential = new StudioScheduledCredential( - pending.ApiKeyId, - pending.SecretReference.Clone(), - DateTimeOffset.FromUnixTimeMilliseconds(pending.KeyExpiresAtUnixMs), - outcome.PendingRevocationOwner!, - pending.DurableOperationGrants?.Select(static grant => grant.Clone()).ToArray()); - var credentialMaterializer = _credentialMaterializer - ?? throw new InvalidOperationException("workflow_external_trigger_credential_materializer_not_configured"); - try - { - return await credentialMaterializer.RevokeAsync( - bearerToken, - authenticatedOwner, - credential, - outcome.NyxIdRevocationPending, - outcome.VaultRevocationPending, - ct); - } - catch (UnauthorizedAccessException) - { - return new StudioScheduledCredentialRevocationResult( - !outcome.NyxIdRevocationPending, - !outcome.VaultRevocationPending, - "credential_owner_mismatch"); - } - catch (Exception) when (!ct.IsCancellationRequested) - { - return new StudioScheduledCredentialRevocationResult( - !outcome.NyxIdRevocationPending, - !outcome.VaultRevocationPending, - "credential_revocation_transient"); - } - } - - private async Task TryRecordFailureAsync( - string scheduleId, - TeamMemberAutomationOwner owner, - string operationId, - string idempotencyKey, - string effectAttemptId, - string errorCode, - CancellationToken ct) - { - try - { - var failure = await _scheduleService.FailTeamAutomationCredentialOperationAsync( - scheduleId, - owner, - operationId, - idempotencyKey, - effectAttemptId, - errorCode, - ct); - return failure.Outcome; - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to record external trigger credential operation failure for schedule {ScheduleId} and operation {OperationId}.", - scheduleId, - operationId); - return null; - } - } - - private static ScheduledInvocationAuthorizationFact ToScheduleAuthorizationFact( - ScheduledInvocationAuthorizationPlan plan) - { - var policy = plan.CredentialPolicy - ?? throw new InvalidOperationException("scheduled_authorization_policy_missing"); - var catalog = plan.CatalogAuthority; - var disclosure = plan.Disclosures.ToHashSet(); - return new ScheduledInvocationAuthorizationFact( - plan.PermissionDigest, - policy.PolicyVersion, - new ScheduledInvocationAuthorizationOwner( - plan.Owner.Authority, - plan.Owner.OwnerKind.ToString(), - plan.Owner.OwnerSubject), - plan.NyxIdServiceGrants.Select(static grant => - new ScheduledInvocationAuthorizationServiceGrant( - grant.UserServiceId, - grant.NodeIds.ToArray(), - grant.NodeGrantRequirement == AuthorizationGrantRequirement.NotRequired)).ToArray(), - string.Join(' ', policy.Scopes.Select(ToScopeName).Order(StringComparer.Ordinal)), - policy.ExpiresAt.ToDateTimeOffset(), - policy.ServiceGrantRequirement == AuthorizationGrantRequirement.NotRequired, - new ScheduledInvocationAuthorizationDisclosure( - disclosure.Contains(ScheduledInvocationDisclosure.DedicatedCredential), - disclosure.Contains(ScheduledInvocationDisclosure.AevatarSecretCustody), - !disclosure.Contains(ScheduledInvocationDisclosure.BrowserNeverReceivesSecret), - disclosure.Contains(ScheduledInvocationDisclosure.DeleteRevokesCredential), - !disclosure.Contains(ScheduledInvocationDisclosure.PauseResumePreservesCredential)), - new ScheduledInvocationAuthorizationAuthority( - SourceVersion(plan, AuthorizationSourceKind.StudioMember), - SourceVersion(plan, AuthorizationSourceKind.WorkflowRevision), - SourceVersion(plan, AuthorizationSourceKind.ConnectorCatalog), - SourceVersion(plan, AuthorizationSourceKind.OwnerLlmRoute), - catalog?.ActorStateVersion ?? 0, - catalog?.ObservedAt?.ToDateTimeOffset() ?? default, - catalog?.FreshUntil?.ToDateTimeOffset() ?? default, - catalog?.ContentDigest ?? string.Empty, - catalog?.ContractVersion ?? string.Empty, - catalog?.PolicyVersion ?? string.Empty, - catalog?.EvaluatedAt?.ToDateTimeOffset() ?? default), - plan.OwnerLlmSelection?.Clone()); - } - - private static string ToScopeName(NyxIdCredentialScope scope) => scope switch - { - NyxIdCredentialScope.Read => "read", - NyxIdCredentialScope.Proxy => "proxy", - _ => throw new InvalidOperationException("scheduled_authorization_scope_invalid"), - }; - - private static long SourceVersion( - ScheduledInvocationAuthorizationPlan plan, - AuthorizationSourceKind sourceKind) => - plan.SourceStamps.FirstOrDefault(stamp => stamp.SourceKind == sourceKind)?.StateVersion ?? 0; - - private static ScheduledInvocationAuthorizationFact CloneScheduleAuthorizationFact( - ScheduledInvocationAuthorizationFact fact) => - new( - fact.PermissionDigest, - fact.PolicyVersion, - new ScheduledInvocationAuthorizationOwner( - fact.Owner.Authority, - fact.Owner.OwnerKind, - fact.Owner.OwnerSubject), - fact.ServiceGrants.Select(static grant => - new ScheduledInvocationAuthorizationServiceGrant( - grant.ServiceId, - grant.NodeIds.ToArray(), - grant.NodeGrantsNotRequired)).ToArray(), - fact.Scopes, - fact.ExpiresAt, - fact.ServiceGrantsNotRequired, - new ScheduledInvocationAuthorizationDisclosure( - fact.Disclosure.DedicatedToSchedule, - fact.Disclosure.SecretManagedByAevatar, - fact.Disclosure.BrowserReceivesRawKey, - fact.Disclosure.DeleteRevokesCredential, - fact.Disclosure.PauseResumeRevokesCredential), - new ScheduledInvocationAuthorizationAuthority( - fact.Authority.MemberStateVersion, - fact.Authority.WorkflowStateVersion, - fact.Authority.ConnectorStateVersion, - fact.Authority.OwnerLlmStateVersion, - fact.Authority.CatalogStateVersion, - fact.Authority.CatalogObservedAt, - fact.Authority.CatalogFreshUntil, - fact.Authority.CatalogContentDigest, - fact.Authority.CatalogContractVersion, - fact.Authority.CatalogPolicyVersion, - fact.Authority.CatalogEvaluatedAt), - fact.OwnerLLMSelection?.Clone()); - - private static string BuildTeamAutomationMutationDigest(TeamAutomationActivationDecision decision) - { - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - AppendDigestValue(hash, "aevatar.workflow-external-trigger-mutation.v1"); - AppendDigestValue(hash, decision.ScheduleId); - AppendDigestValue(hash, decision.DisplayName); - AppendDigestValue(hash, decision.Owner.ScopeId); - AppendDigestValue(hash, decision.Owner.MemberId); - AppendDigestValue(hash, decision.Owner.TeamId); - AppendDigestValue(hash, decision.ServiceIdentity.TenantId); - AppendDigestValue(hash, decision.ServiceIdentity.AppId); - AppendDigestValue(hash, decision.ServiceIdentity.Namespace); - AppendDigestValue(hash, decision.ServiceIdentity.ServiceId); - AppendDigestValue(hash, decision.EndpointId); - AppendDigestValue(hash, decision.Payload.TypeUrl); - AppendDigestBytes(hash, decision.Payload.Value.Span); - AppendDigestValue(hash, decision.CallerAuthority.Platform); - AppendDigestValue(hash, decision.CallerAuthority.Tenant); - AppendDigestValue(hash, decision.CallerAuthority.ExternalUserId); - AppendDigestValue(hash, decision.CallerAuthority.Scope); - AppendDigestValue(hash, decision.CallerAuthority.BindingId); - AppendAuthorizationFactDigest(hash, decision.AuthorizationFact); - AppendDigestValue(hash, decision.CronExpression); - AppendDigestValue(hash, decision.Timezone); - AppendDigestBoolean(hash, decision.Enabled); - AppendDigestInt64(hash, (long)decision.ScheduleKind); - AppendDigestInt64(hash, decision.Headers.Count); - foreach (var (key, value) in decision.Headers.OrderBy(static entry => entry.Key, StringComparer.Ordinal)) - { - AppendDigestValue(hash, key); - AppendDigestValue(hash, value); - } - AppendDigestInt64(hash, (long)decision.ScheduleMode); - AppendDigestBoolean(hash, decision.OneShotFireAt.HasValue); - if (decision.OneShotFireAt.HasValue) - AppendDigestInt64(hash, decision.OneShotFireAt.Value.ToUniversalTime().UtcTicks); - AppendDigestInt64(hash, (long)decision.CredentialRequirementTargetKind); - AppendDigestValue(hash, decision.RevisionId); - AppendDigestBoolean(hash, decision.Caller != null); - if (decision.Caller != null) - { - AppendDigestValue(hash, decision.Caller.ServiceKey); - AppendDigestValue(hash, decision.Caller.TenantId); - AppendDigestValue(hash, decision.Caller.AppId); - } - return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); - } - - private static void AppendAuthorizationFactDigest( - IncrementalHash hash, - ScheduledInvocationAuthorizationFact fact) - { - AppendDigestValue(hash, fact.PermissionDigest); - AppendDigestValue(hash, fact.PolicyVersion); - AppendDigestValue(hash, fact.Owner.Authority); - AppendDigestValue(hash, fact.Owner.OwnerKind); - AppendDigestValue(hash, fact.Owner.OwnerSubject); - var grants = fact.ServiceGrants - .OrderBy(static grant => grant.ServiceId, StringComparer.Ordinal) - .ThenBy(static grant => grant.NodeGrantsNotRequired) - .ThenBy(static grant => string.Join('\n', grant.NodeIds.Order(StringComparer.Ordinal)), StringComparer.Ordinal) - .ToArray(); - AppendDigestInt64(hash, grants.Length); - foreach (var grant in grants) - { - AppendDigestValue(hash, grant.ServiceId); - AppendDigestBoolean(hash, grant.NodeGrantsNotRequired); - var nodeIds = grant.NodeIds.Order(StringComparer.Ordinal).ToArray(); - AppendDigestInt64(hash, nodeIds.Length); - foreach (var nodeId in nodeIds) - AppendDigestValue(hash, nodeId); - } - AppendDigestValue(hash, fact.Scopes); - AppendDigestInt64(hash, fact.ExpiresAt.ToUniversalTime().UtcTicks); - AppendDigestBoolean(hash, fact.ServiceGrantsNotRequired); - AppendDigestBoolean(hash, fact.Disclosure.DedicatedToSchedule); - AppendDigestBoolean(hash, fact.Disclosure.SecretManagedByAevatar); - AppendDigestBoolean(hash, fact.Disclosure.BrowserReceivesRawKey); - AppendDigestBoolean(hash, fact.Disclosure.DeleteRevokesCredential); - AppendDigestBoolean(hash, fact.Disclosure.PauseResumeRevokesCredential); - AppendDigestInt64(hash, fact.Authority.MemberStateVersion); - AppendDigestInt64(hash, fact.Authority.WorkflowStateVersion); - AppendDigestInt64(hash, fact.Authority.ConnectorStateVersion); - AppendDigestInt64(hash, fact.Authority.OwnerLlmStateVersion); - AppendDigestInt64(hash, fact.Authority.CatalogStateVersion); - AppendDigestInt64(hash, fact.Authority.CatalogObservedAt.ToUniversalTime().UtcTicks); - AppendDigestInt64(hash, fact.Authority.CatalogFreshUntil.ToUniversalTime().UtcTicks); - AppendDigestValue(hash, fact.Authority.CatalogContentDigest); - AppendDigestValue(hash, fact.Authority.CatalogContractVersion); - AppendDigestValue(hash, fact.Authority.CatalogPolicyVersion); - AppendDigestInt64(hash, fact.Authority.CatalogEvaluatedAt.ToUniversalTime().UtcTicks); - AppendDigestBoolean(hash, fact.OwnerLLMSelection != null); - if (fact.OwnerLLMSelection != null) - { - AppendDigestInt64(hash, (long)fact.OwnerLLMSelection.RouteKind); - AppendDigestValue(hash, fact.OwnerLLMSelection.RouteValue); - AppendDigestValue(hash, fact.OwnerLLMSelection.NyxIdUserServiceId); - AppendDigestValue(hash, fact.OwnerLLMSelection.ServiceSlugSnapshot); - AppendDigestValue(hash, fact.OwnerLLMSelection.Model); - } - } - - private static void AppendDigestValue(IncrementalHash hash, string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - Span length = stackalloc byte[sizeof(int)]; - BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); - hash.AppendData(length); - hash.AppendData(bytes); - } - - private static void AppendDigestBytes(IncrementalHash hash, ReadOnlySpan bytes) - { - Span length = stackalloc byte[sizeof(int)]; - BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); - hash.AppendData(length); - hash.AppendData(bytes); - } - - private static void AppendDigestBoolean(IncrementalHash hash, bool value) => - hash.AppendData(value ? [1] : [0]); - - private static void AppendDigestInt64(IncrementalHash hash, long value) - { - Span bytes = stackalloc byte[sizeof(long)]; - BinaryPrimitives.WriteInt64BigEndian(bytes, value); - hash.AppendData(bytes); - } - - private static void EnsureRequiredDisclosures(ScheduledInvocationAuthorizationPlan plan) - { - var disclosures = plan.Disclosures.ToHashSet(); - var required = new[] - { - ScheduledInvocationDisclosure.DedicatedCredential, - ScheduledInvocationDisclosure.AevatarSecretCustody, - ScheduledInvocationDisclosure.BrowserNeverReceivesSecret, - ScheduledInvocationDisclosure.DeleteRevokesCredential, - ScheduledInvocationDisclosure.PauseResumePreservesCredential, - }; - if (required.Any(disclosure => !disclosures.Contains(disclosure))) - throw new InvalidOperationException("scheduled_authorization_disclosures_missing"); - } - - private static void EnsureCredentialMatchesPlan( - StudioScheduledCredential credential, - ScheduledInvocationAuthorizationPlan plan, - DateTimeOffset now) - { - if (credential.ExpiresAtUtc <= now || - credential.ExpiresAtUtc > plan.CredentialPolicy.ExpiresAt.ToDateTimeOffset()) - throw new InvalidOperationException("scheduled_credential_expiry_mismatch"); - if (!string.Equals( - credential.SecretReference.Purpose, - CredentialSecretPurposes.ScheduledInvocationAgentKey, - StringComparison.Ordinal)) - throw new InvalidOperationException("scheduled_credential_purpose_mismatch"); - var owner = new ScheduledInvocationAuthorizationOwner( - plan.Owner.Authority, - plan.Owner.OwnerKind.ToString(), - plan.Owner.OwnerSubject); - if (credential.Owner != owner) - throw new InvalidOperationException("credential_owner_mismatch"); - } - - private static ScheduledInvocationAuthorizationOwner ToAuthorizationOwner( - AuthenticatedAuthorizationOwnerContext authenticatedOwner) => - new( - NormalizeRequired(authenticatedOwner.Owner.Authority, nameof(authenticatedOwner.Owner.Authority)), - authenticatedOwner.Owner.OwnerKind.ToString(), - NormalizeRequired(authenticatedOwner.Owner.OwnerSubject, nameof(authenticatedOwner.Owner.OwnerSubject))); - - private static OwnerScope BuildOwnerScope(AuthenticatedAuthorizationOwnerContext owner) => - string.Equals(owner.SubjectPlatform, OwnerScope.NyxIdPlatform, StringComparison.Ordinal) - ? OwnerScope.ForNyxIdNative(owner.Owner.OwnerSubject) - : OwnerScope.ForChannel( - owner.Owner.OwnerSubject, - owner.SubjectPlatform.Trim().ToLowerInvariant(), - NormalizeRequired(owner.SubjectTenant, nameof(owner.SubjectTenant)), - owner.SubjectExternalUserId); - - private static string BuildOperationIdentity( - TeamAutomationOperationKind kind, - string scheduleId, - string permissionDigest) - { - var identity = Encoding.UTF8.GetBytes($"{kind}\n{scheduleId}\n{permissionDigest}"); - return $"workflow-external-trigger:{Convert.ToHexStringLower(SHA256.HashData(identity).AsSpan(0, 16))}"; - } - - private static string ToStableFailureCode(Exception exception) => exception switch - { - OperationCanceledException => "operation_cancelled", - InvalidOperationException { Message: { Length: > 0 } message } when IsStableErrorCode(message) => message, - _ => "workflow_external_trigger_credential_apply_failed", - }; - - private static bool IsStableErrorCode(string value) => - value.Length <= 128 && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-' or '.'); - - private static string NormalizeRequired(string? value, string paramName) - { - var normalized = value?.Trim(); - return string.IsNullOrWhiteSpace(normalized) - ? throw new ArgumentException($"{paramName} is required.", paramName) - : normalized; - } - - private static string? NormalizeOptional(string? value) - { - var normalized = value?.Trim(); - return string.IsNullOrWhiteSpace(normalized) ? null : normalized; - } -} diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs index 9b7576426e..52e8509ebe 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowScheduleEndpoints.cs @@ -1,16 +1,14 @@ using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; using System.Text.Json.Serialization; using Aevatar.AI.Abstractions; using Aevatar.Capabilities; using Aevatar.Foundation.Abstractions; -using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Abstractions.Schedules; using Aevatar.GAgentService.Abstractions.Services; using Aevatar.GAgentService.Hosting.Endpoints.Schedules; +using Aevatar.Workflow.Infrastructure.CapabilityApi; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -23,12 +21,15 @@ internal static class ScopeWorkflowScheduleEndpoints { private const string ChatEndpointId = "chat"; private const string DefaultWorkflowScheduleNyxIdScope = "proxy"; - private const string ExternalTriggerTeamId = "workflow-external-trigger"; + private const string DefaultExternalTriggerDeliveryIdJsonPath = "event_id"; + private const string DefaultExternalTriggerDeliveryIdHeader = "X-NyxID-Delivery-Id"; + private const string DefaultExternalTriggerHmacSignatureHeader = "X-NyxID-Signature"; + private const string DefaultExternalTriggerHmacTimestampHeader = "X-NyxID-Timestamp"; public static RouteGroupBuilder MapScopeWorkflowScheduleEndpoints(this RouteGroupBuilder group) { group.MapPost("/{scopeId}/workflows/{workflowId}/external-trigger", UpsertExternalTrigger) - .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -38,8 +39,8 @@ public static RouteGroupBuilder MapScopeWorkflowScheduleEndpoints(this RouteGrou .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{scopeId}/workflow-triggers/{triggerId}:fire", FireExternalTrigger) - .Produces(StatusCodes.Status202Accepted) + group.MapDelete("/{scopeId}/workflows/{workflowId}/external-trigger", DeleteExternalTrigger) + .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -107,77 +108,33 @@ internal static async Task UpsertExternalTrigger( string workflowId, WorkflowExternalTriggerConfigurationHttpRequest input, [FromServices] IScopeWorkflowQueryPort workflowQueryPort, - [FromServices] IScheduledDispatchApplicationService schedules, - [FromServices] IWorkflowExternalTriggerProvisioningPort externalTriggerProvisioning, - [FromServices] IExternalIdentityBindingQueryPort bindingQuery, CancellationToken ct = default) { var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); if (resolved.Result != null) return resolved.Result; - ScheduledDispatchConfiguration configuration; - ScheduledDispatchMutationContext context; - try - { - context = ResolveMutationContext(http, resolved.Workflow!); - configuration = BuildExternalTriggerConfiguration( - resolved.Workflow!, - input, - BuildExternalTriggerId(resolved.Workflow!), - context.AuthenticatedNyxIdOwnerSubject); - } - catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleConfigurationError(ex, out var result)) - { - return result; - } - - var existing = await schedules.GetAsync(configuration.ScheduleId, ct); - if (existing != null && !BelongsToWorkflow(existing.Schedule, resolved.Workflow!)) - { - return Results.Conflict(new - { - code = "WORKFLOW_EXTERNAL_TRIGGER_ID_CONFLICT", - message = $"Workflow trigger '{configuration.ScheduleId}' is already owned by another service target.", - }); - } - - if (existing?.Schedule.Deleted == true) - { - return Results.Conflict(new - { - code = "WORKFLOW_EXTERNAL_TRIGGER_TOMBSTONED", - message = $"Workflow trigger '{configuration.ScheduleId}' is permanently deleted and cannot be reused.", - }); - } + var routeKey = await ResolveExternalTriggerRouteKeyAsync(http, resolved.Workflow!, ct) + ?? GenerateExternalTriggerRouteKey(); + var put = await WorkflowWebhookBindingEndpoints.HandlePutAsync( + http, + scopeId, + routeKey, + BuildExternalTriggerBindingRequest(resolved.Workflow!, input), + ct); + if (!IsSuccessStatus(put)) + return put; - try - { - var authority = await StudioMemberAutomationHttpAuthorityResolver.ResolveAsync( - http, - bindingQuery, - context.AuthenticatedNyxIdOwnerSubject?.ExternalUserId, - ct); - var provisioned = await externalTriggerProvisioning.ProvisionAsync( - resolved.Workflow!, - configuration, - context, - authority, - ct); - var response = WorkflowExternalTriggerHttpResult.FromMutation( - resolved.Workflow!, - provisioned.Configuration, - provisioned.Receipt, - provisioned.CredentialSourceKind, - provisioned.CredentialExpiresAt, - provisioned.PermissionDigest, - provisioned.PolicyVersion); - return Results.Accepted(BuildExternalTriggerLocation(scopeId, provisioned.Receipt.ScheduleId), response); - } - catch (Exception ex) when (TryMapExternalTriggerProvisioningError(ex, out var result)) - { - return result; - } + var record = await ResolveExternalTriggerBindingAsync(http, resolved.Workflow!, routeKey, ct); + return record == null + ? Results.Json( + new + { + code = "WORKFLOW_EXTERNAL_TRIGGER_BINDING_NOT_FOUND", + message = "External trigger binding was not found after upsert.", + }, + statusCode: StatusCodes.Status409Conflict) + : Results.Ok(WorkflowExternalTriggerHttpResult.FromBinding(resolved.Workflow!, record)); } internal static async Task GetExternalTrigger( @@ -185,72 +142,38 @@ internal static async Task GetExternalTrigger( string scopeId, string workflowId, [FromServices] IScopeWorkflowQueryPort workflowQueryPort, - [FromServices] IScheduledDispatchApplicationService schedules, CancellationToken ct = default) { var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); if (resolved.Result != null) return resolved.Result; - var triggerId = BuildExternalTriggerId(resolved.Workflow!); - var detail = await schedules.GetTeamAutomationAsync( - triggerId, - BuildExternalTriggerOwner(resolved.Workflow!), - ct); - if (detail == null || !BelongsToWorkflow(detail.Schedule, resolved.Workflow!)) - { - return Results.Ok(WorkflowExternalTriggerHttpResult.NotConfigured( - resolved.Workflow!, - triggerId)); - } - - return Results.Ok(WorkflowExternalTriggerHttpResult.FromSummary( - configured: true, - resolved.Workflow!, - detail.Schedule)); + var record = await ResolveExternalTriggerBindingAsync(http, resolved.Workflow!, routeKey: null, ct); + return Results.Ok(record == null + ? WorkflowExternalTriggerHttpResult.NotConfigured(resolved.Workflow!) + : WorkflowExternalTriggerHttpResult.FromBinding(resolved.Workflow!, record)); } - internal static async Task FireExternalTrigger( + internal static async Task DeleteExternalTrigger( HttpContext http, string scopeId, - string triggerId, - [FromServices] IScheduledDispatchApplicationService schedules, + string workflowId, + [FromServices] IScopeWorkflowQueryPort workflowQueryPort, CancellationToken ct = default) { - if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) - return denied; - if (TryCreateInvalidScheduleIdResult(triggerId, out var invalidTriggerId)) - return invalidTriggerId; + var resolved = await ResolveWorkflowAsync(http, scopeId, workflowId, workflowQueryPort, ct); + if (resolved.Result != null) + return resolved.Result; - var detail = await schedules.GetTeamScheduleAsync( - triggerId, - scopeId, - ExternalTriggerTeamId, - ct: ct); - if (detail == null || !IsWorkflowExternalTriggerForScope(detail.Schedule, scopeId, triggerId)) - { - return Results.NotFound(new - { - code = "WORKFLOW_EXTERNAL_TRIGGER_NOT_FOUND", - message = $"Workflow trigger '{triggerId}' was not found for scope '{scopeId}'.", - }); - } + var record = await ResolveExternalTriggerBindingAsync(http, resolved.Workflow!, routeKey: null, ct); + if (record == null) + return Results.NotFound(); - var owner = BuildExternalTriggerOwner(detail.Schedule); - try - { - var receipt = await schedules.RunTeamAutomationNowAsync( - triggerId, - owner, - ct); - return Results.Accepted( - BuildExternalTriggerLocation(scopeId, triggerId), - WorkflowExternalTriggerFireHttpResult.FromReceipt(receipt)); - } - catch (Exception ex) when (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out var result)) - { - return result; - } + return await WorkflowWebhookBindingEndpoints.HandleDeleteAsync( + http, + scopeId, + record.RouteKey, + ct); } internal static async Task Create( @@ -655,26 +578,70 @@ private static bool BelongsToWorkflow(ScheduledDispatchSummary schedule, ScopeWo string.Equals(schedule.ServiceKey, workflow.ServiceKey, StringComparison.Ordinal); } - private static ScheduledDispatchConfiguration BuildExternalTriggerConfiguration( + private static WorkflowWebhookBindingEndpoints.PutWorkflowWebhookBindingRequest BuildExternalTriggerBindingRequest( ScopeWorkflowSummary workflow, - WorkflowExternalTriggerConfigurationHttpRequest input, - string triggerId, - ScheduledServiceInvocationNyxIdSubjectRef? authenticatedOwnerSubject) => - BuildConfiguration( - workflow, - new WorkflowScheduleConfigurationHttpRequest - { - ScheduleId = triggerId, - DisplayName = input.DisplayName, - CronExpression = input.CronExpression, - Timezone = input.Timezone, - Enabled = input.Enabled, - Prompt = input.Prompt, - ScheduleMode = input.ScheduleMode, - OneShotFireAt = input.OneShotFireAt, - }, - triggerId, - authenticatedOwnerSubject); + WorkflowExternalTriggerConfigurationHttpRequest input) => + new( + WorkflowName: workflow.WorkflowName, + SourceId: input.SourceId, + PromptTemplate: input.PromptTemplate, + PromptJsonPath: input.PromptJsonPath, + DeliveryIdHeader: input.DeliveryIdHeader ?? DefaultExternalTriggerDeliveryIdHeader, + DeliveryIdJsonPath: input.DeliveryIdJsonPath ?? DefaultExternalTriggerDeliveryIdJsonPath, + HmacSecret: input.HmacSecret, + HmacSignatureHeader: input.HmacSignatureHeader ?? DefaultExternalTriggerHmacSignatureHeader, + HmacTimestampHeader: input.HmacTimestampHeader ?? DefaultExternalTriggerHmacTimestampHeader, + MaxTimestampSkewSeconds: input.MaxTimestampSkewSeconds, + DefinitionActorId: workflow.ActorId, + TargetRevisionId: workflow.ActiveRevisionId, + PreviousHmacSecret: input.PreviousHmacSecret, + TimeZoneId: input.TimeZoneId, + EnableUnattendedEffects: input.EnableUnattendedEffects); + + private static async Task ResolveExternalTriggerRouteKeyAsync( + HttpContext http, + ScopeWorkflowSummary workflow, + CancellationToken ct) + { + var record = await ResolveExternalTriggerBindingAsync(http, workflow, routeKey: null, ct); + return record?.RouteKey; + } + + private static async Task ResolveExternalTriggerBindingAsync( + HttpContext http, + ScopeWorkflowSummary workflow, + string? routeKey, + CancellationToken ct) + { + var store = http.RequestServices.GetService(typeof(IWorkflowWebhookBindingStore)) as IWorkflowWebhookBindingStore; + if (store == null) + return null; + + if (!string.IsNullOrWhiteSpace(routeKey)) + { + var record = await store.GetAsync(routeKey.Trim(), ct); + return IsExternalTriggerBinding(record, workflow) ? record : null; + } + + var records = await store.ListByScopeAsync(workflow.ScopeId, ct); + return records.FirstOrDefault(record => IsExternalTriggerBinding(record, workflow)); + } + + private static bool IsExternalTriggerBinding( + WorkflowWebhookBindingRecord? record, + ScopeWorkflowSummary workflow) => + record != null && + string.Equals(record.ScopeId, workflow.ScopeId, StringComparison.Ordinal) && + string.Equals(record.DefinitionActorId, workflow.ActorId, StringComparison.Ordinal) && + string.Equals(record.TargetRevisionId, workflow.ActiveRevisionId, StringComparison.Ordinal) && + string.Equals(record.WorkflowName, workflow.WorkflowName, StringComparison.OrdinalIgnoreCase); + + private static string GenerateExternalTriggerRouteKey() => + $"workflow-external-trigger-{Guid.NewGuid():N}"; + + private static bool IsSuccessStatus(IResult result) => + result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 or null } || + result is not IStatusCodeHttpResult; private static ScheduledDispatchConfiguration BuildConfiguration( ScopeWorkflowSummary workflow, @@ -834,76 +801,6 @@ private static string NormalizeRequired(string? value, string paramName) => return null; } - private static string BuildExternalTriggerId(ScopeWorkflowSummary workflow) - { - var triggerKey = string.Join( - ":", - workflow.ScopeId.Trim(), - workflow.ActorId.Trim(), - workflow.PublishedServiceId.Trim()); - var triggerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(triggerKey))) - .ToLowerInvariant()[..32]; - return $"workflow-trigger-{triggerHash}"; - } - - private static string BuildExternalTriggerLocation(string scopeId, string triggerId) => - $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflow-triggers/{Uri.EscapeDataString(triggerId)}:fire"; - - private static TeamMemberAutomationOwner BuildExternalTriggerOwner(ScopeWorkflowSummary workflow) => - new( - NormalizeRequired(workflow.ScopeId, nameof(workflow.ScopeId)), - NormalizeRequired(workflow.WorkflowId, nameof(workflow.WorkflowId)), - ExternalTriggerTeamId); - - private static TeamMemberAutomationOwner BuildExternalTriggerOwner(ScheduledDispatchSummary schedule) => - new( - NormalizeRequired(schedule.TeamOwnerScopeId, nameof(schedule.TeamOwnerScopeId)), - NormalizeRequired(schedule.TeamOwnerMemberId, nameof(schedule.TeamOwnerMemberId)), - NormalizeRequired(schedule.TeamId, nameof(schedule.TeamId))); - - private static bool TryMapExternalTriggerProvisioningError(Exception ex, out IResult result) - { - if (ScheduledDispatchEndpoints.TryMapScheduleMutationError(ex, out result)) - return true; - - switch (ex) - { - case UnauthorizedAccessException unauthorized: - result = Results.Json( - new - { - code = "WORKFLOW_EXTERNAL_TRIGGER_AUTHORIZATION_REQUIRED", - message = unauthorized.Message, - }, - statusCode: StatusCodes.Status401Unauthorized); - return true; - case InvalidOperationException invalid: - result = Results.BadRequest(new - { - code = "WORKFLOW_EXTERNAL_TRIGGER_PROVISIONING_FAILED", - message = invalid.Message, - }); - return true; - default: - result = Results.Empty; - return false; - } - } - - private static bool IsWorkflowExternalTriggerForScope( - ScheduledDispatchSummary schedule, - string scopeId, - string triggerId) => - schedule.ScheduleId == triggerId && - schedule.TeamOwned && - string.Equals(schedule.TeamId, ExternalTriggerTeamId, StringComparison.Ordinal) && - string.Equals(schedule.TeamOwnerScopeId, scopeId, StringComparison.Ordinal) && - schedule.ScheduleKind == ScheduledDispatchScheduleKind.Workflow && - schedule.TargetKind == ScheduledDispatchTargetKind.ServiceInvocation && - string.Equals(schedule.ServiceEndpointId, ChatEndpointId, StringComparison.Ordinal) && - string.Equals(schedule.ServiceIdentity.TenantId, scopeId, StringComparison.Ordinal) && - schedule.CredentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey; - private static string BuildWorkflowScheduleLocation(string scopeId, string workflowId, string scheduleId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/workflows/{Uri.EscapeDataString(workflowId)}/schedules/{Uri.EscapeDataString(scheduleId)}"; @@ -919,143 +816,80 @@ private sealed record WorkflowScheduleOwnershipResult( [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] public sealed record WorkflowExternalTriggerConfigurationHttpRequest { - public string? DisplayName { get; init; } - public string? CronExpression { get; init; } - public string? Timezone { get; init; } - public bool Enabled { get; init; } = true; - public string? Prompt { get; init; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public ScheduledDispatchScheduleMode ScheduleMode { get; init; } = ScheduledDispatchScheduleMode.RecurringCron; - public DateTimeOffset? OneShotFireAt { get; init; } + public string? SourceId { get; init; } + public string? PromptTemplate { get; init; } + public string? PromptJsonPath { get; init; } + public string? DeliveryIdHeader { get; init; } + public string? DeliveryIdJsonPath { get; init; } + public string? HmacSecret { get; init; } + public string? PreviousHmacSecret { get; init; } + public string? HmacSignatureHeader { get; init; } + public string? HmacTimestampHeader { get; init; } + public int? MaxTimestampSkewSeconds { get; init; } + public string? TimeZoneId { get; init; } + public bool EnableUnattendedEffects { get; init; } = true; } public sealed record WorkflowExternalTriggerHttpResult { public bool Configured { get; init; } - public string AcceptanceStage { get; init; } = string.Empty; - public string CommandId { get; init; } = string.Empty; - public string CorrelationId { get; init; } = string.Empty; - public DateTimeOffset? AcceptedAt { get; init; } - public required string TriggerId { get; init; } public required string WorkflowId { get; init; } public required string ServiceId { get; init; } public string RevisionId { get; init; } = string.Empty; - public string EndpointId { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; - public bool Enabled { get; init; } - public string CronExpression { get; init; } = string.Empty; - public string Timezone { get; init; } = string.Empty; - [JsonConverter(typeof(JsonStringEnumConverter))] - public ScheduledDispatchScheduleMode ScheduleMode { get; init; } - public DateTimeOffset? OneShotFireAt { get; init; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public ScheduledDispatchCredentialSourceKind CredentialSourceKind { get; init; } + public string RouteKey { get; init; } = string.Empty; + public string FireUrl { get; init; } = string.Empty; + public string SourceId { get; init; } = string.Empty; + public string DefinitionActorId { get; init; } = string.Empty; + public string TargetRevisionId { get; init; } = string.Empty; + public string DeliveryIdHeader { get; init; } = string.Empty; + public string DeliveryIdJsonPath { get; init; } = string.Empty; + public string HmacSignatureHeader { get; init; } = string.Empty; + public string HmacTimestampHeader { get; init; } = string.Empty; + public bool HmacSecretSet { get; init; } + public bool PreviousHmacSecretSet { get; init; } public bool AgentKeyReady { get; init; } - public DateTimeOffset? CredentialExpiresAt { get; init; } - public string PermissionDigest { get; init; } = string.Empty; - public string PolicyVersion { get; init; } = string.Empty; + public bool UnattendedEffectsEnabled { get; init; } + public long UpdatedAtUnixMs { get; init; } - public static WorkflowExternalTriggerHttpResult FromMutation( + public static WorkflowExternalTriggerHttpResult FromBinding( ScopeWorkflowSummary workflow, - ScheduledDispatchConfiguration configuration, - ScheduledDispatchMutationReceipt receipt, - ScheduledDispatchCredentialSourceKind credentialSourceKind, - DateTimeOffset? CredentialExpiresAt, - string PermissionDigest, - string PolicyVersion) - { - var invocation = configuration.Target.ServiceInvocation!; - return new WorkflowExternalTriggerHttpResult - { - Configured = false, - AcceptanceStage = receipt.AckStage, - CommandId = receipt.CommandId, - CorrelationId = receipt.CorrelationId, - AcceptedAt = receipt.AckedAt, - TriggerId = receipt.ScheduleId, - WorkflowId = workflow.WorkflowId, - ServiceId = invocation.Identity.ServiceId, - RevisionId = invocation.RevisionId ?? workflow.ActiveRevisionId, - EndpointId = invocation.EndpointId, - Status = "pending", - Enabled = configuration.Enabled, - CronExpression = configuration.CronExpression, - Timezone = configuration.Timezone, - ScheduleMode = configuration.ScheduleMode, - OneShotFireAt = configuration.OneShotFireAt, - CredentialSourceKind = credentialSourceKind, - AgentKeyReady = credentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - CredentialExpiresAt = CredentialExpiresAt, - PermissionDigest = PermissionDigest, - PolicyVersion = PolicyVersion, - }; - } - - public static WorkflowExternalTriggerHttpResult FromSummary( - bool configured, - ScopeWorkflowSummary workflow, - ScheduledDispatchSummary schedule) => + WorkflowWebhookBindingRecord record) => new() { - Configured = configured, - TriggerId = schedule.ScheduleId, + Configured = true, WorkflowId = workflow.WorkflowId, - ServiceId = schedule.ServiceId, - RevisionId = schedule.ServiceRevisionId, - EndpointId = schedule.ServiceEndpointId, - Status = schedule.Deleted ? "deleted" : schedule.Enabled ? "enabled" : "disabled", - Enabled = schedule.Enabled, - CronExpression = schedule.CronExpression, - Timezone = schedule.Timezone, - ScheduleMode = schedule.ScheduleMode, - OneShotFireAt = schedule.OneShotFireAt, - CredentialSourceKind = schedule.CredentialSourceKind, - AgentKeyReady = schedule.CredentialSourceKind == ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - CredentialExpiresAt = schedule.CredentialExpiresAt, - PermissionDigest = schedule.PermissionDigest, - PolicyVersion = schedule.PolicyVersion, + ServiceId = workflow.PublishedServiceId, + RevisionId = record.TargetRevisionId ?? workflow.ActiveRevisionId, + Status = record.CallerDurableCredential != null ? "active" : "configured", + RouteKey = record.RouteKey, + FireUrl = BuildWebhookFireUrl(record.RouteKey), + SourceId = record.SourceId ?? string.Empty, + DefinitionActorId = record.DefinitionActorId ?? string.Empty, + TargetRevisionId = record.TargetRevisionId ?? string.Empty, + DeliveryIdHeader = record.DeliveryIdHeader ?? string.Empty, + DeliveryIdJsonPath = record.DeliveryIdJsonPath ?? string.Empty, + HmacSignatureHeader = record.HmacSignatureHeader ?? string.Empty, + HmacTimestampHeader = record.HmacTimestampHeader ?? string.Empty, + HmacSecretSet = !string.IsNullOrWhiteSpace(record.HmacSecret), + PreviousHmacSecretSet = !string.IsNullOrWhiteSpace(record.PreviousHmacSecret), + AgentKeyReady = record.CallerDurableCredential != null, + UnattendedEffectsEnabled = record.CallerAuthority != null && record.UnattendedEffectAuthorization != null, + UpdatedAtUnixMs = record.UpdatedAtUnixMs, }; - public static WorkflowExternalTriggerHttpResult NotConfigured( - ScopeWorkflowSummary workflow, - string triggerId) => + public static WorkflowExternalTriggerHttpResult NotConfigured(ScopeWorkflowSummary workflow) => new() { Configured = false, - TriggerId = triggerId, WorkflowId = workflow.WorkflowId, ServiceId = workflow.PublishedServiceId, RevisionId = workflow.ActiveRevisionId, - EndpointId = "chat", Status = "not_configured", - CredentialSourceKind = ScheduledDispatchCredentialSourceKind.None, }; -} -public sealed record WorkflowExternalTriggerFireHttpResult -{ - public required string TriggerId { get; init; } - public bool Accepted { get; init; } - public DateTimeOffset ScheduledFireAt { get; init; } - public required string IdempotencyKey { get; init; } - public required string CommandId { get; init; } - public required string CorrelationId { get; init; } - public DateTimeOffset AckedAt { get; init; } - public string AckStage { get; init; } = string.Empty; - - public static WorkflowExternalTriggerFireHttpResult FromReceipt( - ScheduledDispatchRunNowReceipt receipt) => - new() - { - TriggerId = receipt.ScheduleId, - Accepted = receipt.Accepted, - ScheduledFireAt = receipt.ScheduledFireAt, - IdempotencyKey = receipt.IdempotencyKey, - CommandId = receipt.CommandId, - CorrelationId = receipt.CorrelationId, - AckedAt = receipt.AckedAt, - AckStage = receipt.AckStage, - }; + private static string BuildWebhookFireUrl(string routeKey) => + $"/api/workflow-webhooks/{Uri.EscapeDataString(routeKey)}"; } [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookAgentKeyMaterializer.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookAgentKeyMaterializer.cs index e753e8ab5a..336c6db32b 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookAgentKeyMaterializer.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookAgentKeyMaterializer.cs @@ -11,7 +11,7 @@ namespace Aevatar.Workflow.Infrastructure.CapabilityApi; -internal interface IWorkflowWebhookAgentKeyMaterializer +public interface IWorkflowWebhookAgentKeyMaterializer { Task MaterializeAsync( WorkflowCallerNyxIdAuthority callerAuthority, @@ -27,7 +27,7 @@ Task RevokeAsync( CancellationToken ct); } -internal sealed record WorkflowWebhookAgentKeyMaterializationResult( +public sealed record WorkflowWebhookAgentKeyMaterializationResult( DurableCallerCredentialRef? Credential, int StatusCode, string ErrorCode) diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookBindingEndpoints.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookBindingEndpoints.cs index 13ccd21ccb..c5426edce2 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookBindingEndpoints.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/WorkflowWebhookBindingEndpoints.cs @@ -22,7 +22,7 @@ namespace Aevatar.Workflow.Infrastructure.CapabilityApi; /// ingress at /api/workflow-webhooks/{routeKey} resolves it dynamically — /// no host configuration change or redeploy per workflow. /// -internal static class WorkflowWebhookBindingEndpoints +public static class WorkflowWebhookBindingEndpoints { public static void Map(IEndpointRouteBuilder group) { @@ -51,7 +51,7 @@ public sealed record PutWorkflowWebhookBindingRequest( string? TimeZoneId = null, bool EnableUnattendedEffects = false); - internal static async Task HandlePutAsync( + public static async Task HandlePutAsync( HttpContext http, string scopeId, string routeKey, @@ -359,7 +359,7 @@ internal static async Task HandleListAsync( return Results.Ok(new { bindings = records.Select(ToView).ToArray() }); } - internal static async Task HandleDeleteAsync( + public static async Task HandleDeleteAsync( HttpContext http, string scopeId, string routeKey, diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs index ff6f419ab5..9e94d16cb4 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeWorkflowEndpointsTests.cs @@ -31,6 +31,7 @@ using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using CredentialWorkflowCallerAuthority = Aevatar.Workflow.Abstractions.WorkflowCallerNyxIdAuthority; using Aevatar.Workflow.Application.Abstractions.Runs; +using Aevatar.Workflow.Infrastructure.CapabilityApi; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -1679,23 +1680,22 @@ public async Task HandleUpsertWorkflowAsync_ShouldReturnAccepted_WithLocation_Wh } [Fact] - public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServerOwnedCredential() + public async Task WorkflowExternalTriggerUpsert_ShouldCreateWebhookBindingWithAgentKeyAndFireUrl() { - var http = CreateHttpContext("scope-alpha"); - http.Request.Headers.Authorization = "Bearer transient-provisioning-token"; + var bindingStore = new RecordingWorkflowWebhookBindingStore(); + var materializer = new RecordingWorkflowWebhookAgentKeyMaterializer(); + var tokenProvider = new RecordingWorkflowCallerAccessTokenProvider(); + var http = CreateExternalTriggerHttpContext(bindingStore, materializer, tokenProvider); + http.Request.Headers["X-NyxID-Delegation-Token"] = "proxy-delegation-token"; var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), }; - var schedules = new RecordingWorkflowScheduledDispatchService(); - var provisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); - var bindingQuery = new FakeExternalIdentityBindingQueryPort(); var input = new WorkflowExternalTriggerConfigurationHttpRequest { - DisplayName = "External trigger", - CronExpression = "0 9 * * *", - Timezone = "UTC", - Prompt = "run workflow", + SourceId = "nyxid-external-trigger", + PromptTemplate = "\"Run {{event_id}}\"", + HmacSecret = "delivery-signing-secret-at-least-32-bytes", }; var result = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( @@ -1704,194 +1704,97 @@ public async Task WorkflowExternalTriggerUpsert_ShouldUseDeterministicIdAndServe "wf-alpha", input, workflowQueryPort, - schedules, - provisioner, - bindingQuery, CancellationToken.None); await result.ExecuteAsync(http); var body = await ReadBodyAsync(http.Response); - http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); - body.Should().Contain("\"configured\":false"); - body.Should().Contain("\"status\":\"pending\""); - body.Should().Contain("\"acceptanceStage\":\"accepted\""); - body.Should().Contain("\"credentialSourceKind\":\"ScheduledInvocationAgentKey\""); + http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + body.Should().Contain("\"configured\":true"); + body.Should().Contain("\"status\":\"active\""); + body.Should().Contain("\"fireUrl\":\"/api/workflow-webhooks/workflow-external-trigger-"); + body.Should().Contain("\"routeKey\":\"workflow-external-trigger-"); body.Should().Contain("\"agentKeyReady\":true"); - schedules.Ensured.Should().BeEmpty(); - provisioner.Configurations.Should().ContainSingle(); - provisioner.Contexts.Should().ContainSingle().Which!.AuthenticatedNyxIdOwnerSubject - .Should().NotBeNull(); - provisioner.Authorities.Should().ContainSingle().Which.AuthenticatedOwner.VerifiedBindingId - .Should().Be("binding-caller-alpha"); - var configuration = provisioner.Configurations[0]; - configuration.ScheduleId.Should().StartWith("workflow-trigger-"); - configuration.ScheduleId.Should().HaveLength("workflow-trigger-".Length + 32); - configuration.Target.ServiceInvocation!.Auth!.Source - .Should().BeOfType(); - configuration.Target.ServiceInvocation.Payload.Unpack().Prompt - .Should().Be("run workflow"); - - var secondHttp = CreateHttpContext("scope-alpha"); - secondHttp.Request.Headers.Authorization = "Bearer transient-provisioning-token"; - var secondSchedules = new RecordingWorkflowScheduledDispatchService(); - var secondProvisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); + body.Should().Contain("\"hmacSecretSet\":true"); + body.Should().NotContain("delivery-signing-secret-at-least-32-bytes"); + body.Should().NotContain("workflow-triggers"); + bindingStore.Records.Should().ContainSingle(); + var record = bindingStore.Records.Values.Single(); + record.ScopeId.Should().Be("scope-alpha"); + record.WorkflowName.Should().Be("workflow-alpha"); + record.DefinitionActorId.Should().Be("definition-actor-alpha"); + record.TargetRevisionId.Should().Be("rev-alpha"); + record.CallerDurableCredential.Should().NotBeNull(); + tokenProvider.Authorities.Should().ContainSingle().Which.BindingId.Should().Be("binding-caller-alpha"); + materializer.Materialized.Should().ContainSingle().Which.RouteKey.Should().Be(record.RouteKey); + + var secondHttp = CreateExternalTriggerHttpContext(bindingStore, materializer, tokenProvider); + secondHttp.Request.Headers["X-NyxID-Delegation-Token"] = "proxy-delegation-token"; var secondResult = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( secondHttp, "scope-alpha", "wf-alpha", input, workflowQueryPort, - secondSchedules, - secondProvisioner, - bindingQuery, CancellationToken.None); await secondResult.ExecuteAsync(secondHttp); + var secondBody = await ReadBodyAsync(secondHttp.Response); - secondSchedules.Ensured.Should().BeEmpty(); - secondProvisioner.Configurations.Should().ContainSingle(); - secondProvisioner.Configurations[0].ScheduleId.Should().Be(configuration.ScheduleId); - } - - [Fact] - public async Task WorkflowExternalTriggerProvisioning_ShouldIssueProvisioningTokenFromVerifiedBinding() - { - var schedules = new RecordingWorkflowScheduledDispatchService(); - var planner = new RecordingExternalTriggerAuthorizationPlanner(); - var revalidator = new RecordingExternalTriggerAuthorizationRevalidator(planner); - var materializer = new RecordingExternalTriggerCredentialMaterializer(); - var tokenProvider = new RecordingWorkflowCallerAccessTokenProvider(); - var service = new WorkflowExternalTriggerProvisioningService( - schedules, - planner, - revalidator, - TimeProvider.System, - credentialMaterializer: materializer, - workflowEvidenceQueryPort: new RecordingWorkflowEvidenceQueryPort(), - callerAccessTokenProvider: tokenProvider); - var authority = ExternalTriggerAuthority(); - - await service.ProvisionAsync( - RunnableWorkflow().Workflow!, - ExternalTriggerConfiguration(ExternalTriggerId("scope-alpha", "wf-alpha")), - new ScheduledDispatchMutationContext(AuthenticatedScopeId: "scope-alpha"), - authority, - CancellationToken.None); - - tokenProvider.Authorities.Should().ContainSingle().Which.Should().BeEquivalentTo(new CredentialWorkflowCallerAuthority - { - Platform = OwnerScope.NyxIdPlatform, - Tenant = string.Empty, - ExternalUserId = "caller-alpha", - Scope = "proxy", - BindingId = "binding-caller-alpha", - }); - materializer.BearerToken.Should().Be("issued-provisioning-token"); - materializer.BearerToken.Should().NotBe(authority.ProvisioningBearerToken); - schedules.CompletedCredentialOperations.Should().ContainSingle(); - } - - [Fact] - public async Task WorkflowExternalTriggerProvisioning_WhenMaterializationFails_ShouldRecordOperationFailure() - { - var schedules = new RecordingWorkflowScheduledDispatchService(); - var planner = new RecordingExternalTriggerAuthorizationPlanner(); - var revalidator = new RecordingExternalTriggerAuthorizationRevalidator(planner); - var materializer = new RecordingExternalTriggerCredentialMaterializer( - new InvalidOperationException("nyxid_api_key_list_unauthorized")); - var service = new WorkflowExternalTriggerProvisioningService( - schedules, - planner, - revalidator, - TimeProvider.System, - credentialMaterializer: materializer, - workflowEvidenceQueryPort: new RecordingWorkflowEvidenceQueryPort()); - var configuration = ExternalTriggerConfiguration(ExternalTriggerId("scope-alpha", "wf-alpha")); - var authority = ExternalTriggerAuthority(); - - var act = () => service.ProvisionAsync( - RunnableWorkflow().Workflow!, - configuration, - new ScheduledDispatchMutationContext(AuthenticatedScopeId: "scope-alpha"), - authority, - CancellationToken.None); - - await act.Should().ThrowAsync() - .WithMessage("nyxid_api_key_list_unauthorized"); - schedules.BeginCredentialOperations.Should().ContainSingle(); - schedules.FailedCredentialOperations.Should().ContainSingle().Which.ErrorCode - .Should().Be("nyxid_api_key_list_unauthorized"); - schedules.RecordedCredentialCandidates.Should().BeEmpty(); - schedules.CompletedCredentialOperations.Should().BeEmpty(); - materializer.MaterializeCallCount.Should().Be(1); + secondHttp.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + secondBody.Should().Contain($"\"routeKey\":\"{record.RouteKey}\""); + bindingStore.Records.Should().ContainSingle(); + materializer.Revoked.Should().ContainSingle().Which.Credential.Ref.Should().Be("webhook-agent-key-ref-1"); } [Fact] - public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignBindingWithoutMutation() + public async Task WorkflowExternalTriggerUpsert_ShouldRejectForeignRouteBindingWithoutMutation() { - var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); - var http = CreateHttpContext("scope-alpha"); - http.Request.Headers.Authorization = "Bearer transient-provisioning-token"; + var bindingStore = new RecordingWorkflowWebhookBindingStore(); + bindingStore.Records["workflow-external-trigger-foreign"] = ExternalTriggerBindingRecord( + routeKey: "workflow-external-trigger-foreign", + scopeId: "scope-other"); + var http = CreateExternalTriggerHttpContext(bindingStore); var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), }; - var schedules = new RecordingWorkflowScheduledDispatchService - { - Detail = new ScheduledDispatchDetail( - WorkflowScheduleSummary(triggerId) with - { - ServiceIdentity = new ServiceIdentity - { - TenantId = "scope-other", - AppId = "workflow-app", - Namespace = "workflow-ns", - ServiceId = "svc-other", - }, - ServiceId = "svc-other", - }, - []), - }; - var provisioner = new RecordingWorkflowExternalTriggerProvisioningPort(); - var bindingQuery = new FakeExternalIdentityBindingQueryPort(); var result = await ScopeWorkflowScheduleEndpoints.UpsertExternalTrigger( http, "scope-alpha", "wf-alpha", - new WorkflowExternalTriggerConfigurationHttpRequest { Prompt = "run workflow" }, + new WorkflowExternalTriggerConfigurationHttpRequest + { + PromptTemplate = "\"Run {{event_id}}\"", + HmacSecret = "delivery-signing-secret-at-least-32-bytes", + EnableUnattendedEffects = false, + }, workflowQueryPort, - schedules, - provisioner, - bindingQuery, CancellationToken.None); await result.ExecuteAsync(http); - var body = await ReadBodyAsync(http.Response); - http.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); - body.Should().Contain("WORKFLOW_EXTERNAL_TRIGGER_ID_CONFLICT"); - schedules.Ensured.Should().BeEmpty(); - provisioner.Configurations.Should().BeEmpty(); + http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + bindingStore.Records.Should().HaveCount(2); + bindingStore.Records["workflow-external-trigger-foreign"].ScopeId.Should().Be("scope-other"); } [Fact] public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindingIsAbsent() { - var http = CreateHttpContext("scope-alpha"); + var bindingStore = new RecordingWorkflowWebhookBindingStore(); + var http = CreateExternalTriggerHttpContext(bindingStore); var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), }; - var schedules = new RecordingWorkflowScheduledDispatchService(); var result = await ScopeWorkflowScheduleEndpoints.GetExternalTrigger( http, "scope-alpha", "wf-alpha", workflowQueryPort, - schedules, CancellationToken.None); await result.ExecuteAsync(http); @@ -1900,41 +1803,36 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnNotConfiguredWhenBindin http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); body.Should().Contain("\"configured\":false"); body.Should().Contain("\"status\":\"not_configured\""); - schedules.LastScheduleGet.Should().StartWith("workflow-trigger-"); - schedules.LastScheduleGet.Should().HaveLength("workflow-trigger-".Length + 32); - schedules.LastTeamAutomationGet.Should().BeEquivalentTo(new TeamMemberAutomationOwner( - "scope-alpha", - "wf-alpha", - "workflow-external-trigger")); + body.Should().Contain("\"fireUrl\":\"\""); + body.Should().NotContain("workflow-triggers"); } [Fact] - public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredBinding() + public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredWebhookBinding() { - var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); - var http = CreateHttpContext("scope-alpha"); + var bindingStore = new RecordingWorkflowWebhookBindingStore(); + bindingStore.Records["route-alpha"] = ExternalTriggerBindingRecord( + routeKey: "route-alpha", + callerDurableCredential: new DurableCallerCredentialRef + { + Ref = "credential-ref-alpha", + Purpose = CredentialSecretPurposes.WorkflowWebhookBindingAgentKey, + OwnerScopeKey = "scope-alpha", + SubjectId = "caller-alpha", + SourceKind = DurableCallerCredentialSourceKind.WebhookBinding, + ProviderCredentialId = "provider-key-alpha", + }); + var http = CreateExternalTriggerHttpContext(bindingStore); var workflowQueryPort = new RecordingScopeWorkflowQueryPort { LookupResult = RunnableWorkflow(), }; - var schedules = new RecordingWorkflowScheduledDispatchService - { - Detail = new ScheduledDispatchDetail( - ExternalTriggerScheduleSummary(triggerId) with - { - ServiceRevisionId = "rev-alpha", - PermissionDigest = "digest-alpha", - PolicyVersion = "policy-alpha", - }, - []), - }; var result = await ScopeWorkflowScheduleEndpoints.GetExternalTrigger( http, "scope-alpha", "wf-alpha", workflowQueryPort, - schedules, CancellationToken.None); await result.ExecuteAsync(http); @@ -1942,63 +1840,48 @@ public async Task WorkflowExternalTriggerGet_ShouldReturnConfiguredBinding() http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); body.Should().Contain("\"configured\":true"); - body.Should().Contain($"\"triggerId\":\"{triggerId}\""); - body.Should().Contain("\"credentialSourceKind\":\"ScheduledInvocationAgentKey\""); + body.Should().Contain("\"routeKey\":\"route-alpha\""); + body.Should().Contain("\"fireUrl\":\"/api/workflow-webhooks/route-alpha\""); body.Should().Contain("\"agentKeyReady\":true"); - body.Should().Contain("\"permissionDigest\":\"digest-alpha\""); + body.Should().Contain("\"hmacSecretSet\":true"); + body.Should().NotContain("delivery-signing-secret-at-least-32-bytes"); + body.Should().NotContain("workflow-triggers"); } [Fact] - public async Task WorkflowExternalTriggerFire_ShouldRejectMismatchedScopeWithoutMutation() + public async Task WorkflowExternalTriggerDelete_ShouldRemoveBindingAndRevokeCredential() { - var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); - var http = CreateHttpContext("scope-beta"); - var schedules = new RecordingWorkflowScheduledDispatchService - { - Detail = new ScheduledDispatchDetail(ExternalTriggerScheduleSummary(triggerId), []), - }; - - var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( - http, - "scope-beta", - triggerId, - schedules, - CancellationToken.None); - - await result.ExecuteAsync(http); - var body = await ReadBodyAsync(http.Response); - - http.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - body.Should().Contain("WORKFLOW_EXTERNAL_TRIGGER_NOT_FOUND"); - schedules.RunNowScheduleIds.Should().BeEmpty(); - } - - [Fact] - public async Task WorkflowExternalTriggerFire_ShouldRunTeamAutomationWithExternalTriggerOwner() - { - var triggerId = ExternalTriggerId("scope-alpha", "wf-alpha"); - var http = CreateHttpContext("scope-alpha"); - var schedules = new RecordingWorkflowScheduledDispatchService + var bindingStore = new RecordingWorkflowWebhookBindingStore(); + bindingStore.Records["route-alpha"] = ExternalTriggerBindingRecord( + routeKey: "route-alpha", + callerDurableCredential: new DurableCallerCredentialRef + { + Ref = "credential-ref-alpha", + Purpose = CredentialSecretPurposes.WorkflowWebhookBindingAgentKey, + OwnerScopeKey = "scope-alpha", + SubjectId = "caller-alpha", + SourceKind = DurableCallerCredentialSourceKind.WebhookBinding, + ProviderCredentialId = "provider-key-alpha", + }); + var materializer = new RecordingWorkflowWebhookAgentKeyMaterializer(); + var http = CreateExternalTriggerHttpContext(bindingStore, materializer); + var workflowQueryPort = new RecordingScopeWorkflowQueryPort { - Detail = new ScheduledDispatchDetail(ExternalTriggerScheduleSummary(triggerId), []), + LookupResult = RunnableWorkflow(), }; - var result = await ScopeWorkflowScheduleEndpoints.FireExternalTrigger( + var result = await ScopeWorkflowScheduleEndpoints.DeleteExternalTrigger( http, "scope-alpha", - triggerId, - schedules, + "wf-alpha", + workflowQueryPort, CancellationToken.None); await result.ExecuteAsync(http); - http.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); - schedules.RunNowScheduleIds.Should().ContainSingle().Which.Should().Be(triggerId); - schedules.RunNowContexts.Should().BeEmpty(); - schedules.RunNowTeamOwners.Should().ContainSingle().Which.Should().BeEquivalentTo(new TeamMemberAutomationOwner( - "scope-alpha", - "wf-alpha", - "workflow-external-trigger")); + http.Response.StatusCode.Should().Be(StatusCodes.Status204NoContent); + bindingStore.Records.Should().BeEmpty(); + materializer.Revoked.Should().ContainSingle().Which.Credential.Ref.Should().Be("credential-ref-alpha"); } [Fact] @@ -2666,11 +2549,12 @@ private static ScopeWorkflowQueryApplicationService BuildQueryApplicationService private static DefaultHttpContext CreateHttpContext( string scopeId = "user-1", - IUserConfigQueryPort? userConfigQueryPort = null) + IUserConfigQueryPort? userConfigQueryPort = null, + Action? configureServices = null) { var http = new DefaultHttpContext { - RequestServices = BuildRequestServices(userConfigQueryPort), + RequestServices = BuildRequestServices(userConfigQueryPort, configureServices), }; http.Response.Body = new MemoryStream(); http.User = new ClaimsPrincipal( @@ -2711,15 +2595,23 @@ private static DefaultHttpContext CreateAnonymousHttpContext() return http; } - private static ServiceProvider BuildRequestServices(IUserConfigQueryPort? userConfigQueryPort = null) + private static ServiceProvider BuildRequestServices( + IUserConfigQueryPort? userConfigQueryPort = null, + Action? configureServices = null) { var services = new ServiceCollection() .AddLogging() .AddOptions() - .AddSingleton(new ConfigurationBuilder().Build()) + .AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Aevatar:Authentication:Enabled"] = "true", + }) + .Build()) .AddSingleton(new TestHostEnvironment()); if (userConfigQueryPort != null) services.AddSingleton(userConfigQueryPort); + configureServices?.Invoke(services); return services.BuildServiceProvider(); } @@ -2787,13 +2679,6 @@ private static WorkflowRunEventEnvelope BuildRawObservedWorkflowExecutionStarted }, string.Empty); - private static string ExternalTriggerId(string scopeId, string workflowId) - { - var triggerKey = string.Join(":", scopeId, "definition-actor-alpha", "svc-alpha"); - return $"workflow-trigger-{Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(triggerKey))).ToLowerInvariant()[..32]}"; - } - private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleId) => new( scheduleId, @@ -2833,58 +2718,6 @@ private static ScheduledDispatchSummary WorkflowScheduleSummary(string scheduleI ServiceRevisionId = "rev-alpha", }; - private static ScheduledDispatchSummary ExternalTriggerScheduleSummary(string triggerId) => - WorkflowScheduleSummary(triggerId) with - { - TeamOwned = true, - TeamOwnerScopeId = "scope-alpha", - TeamOwnerMemberId = "wf-alpha", - TeamId = "workflow-external-trigger", - CredentialSourceKind = ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - TeamAutomationLifecycleStatus = TeamAutomationLifecycleStatus.Active, - }; - - private static StudioMemberAutomationHttpAuthority ExternalTriggerAuthority() => - new( - new AuthenticatedAuthorizationOwnerContext( - new AuthorizationOwnerIdentity - { - Authority = NyxIdAuthorizationAuthorities.NyxId, - OwnerKind = AuthorizationOwnerKind.Personal, - OwnerSubject = "nyx-owner-alpha", - }, - OwnerScope.NyxIdPlatform, - string.Empty, - "caller-alpha", - "binding-caller-alpha"), - "transient-provisioning-token"); - - private static ScheduledDispatchConfiguration ExternalTriggerConfiguration(string scheduleId) => - new( - scheduleId, - "External trigger", - new ScheduledDispatchTargetDescriptor( - ScheduledDispatchTargetKind.ServiceInvocation, - ServiceInvocation: new ScheduledServiceInvocationTargetDescriptor( - new ServiceIdentity - { - TenantId = "scope-alpha", - AppId = "workflow-app", - Namespace = "workflow-ns", - ServiceId = "svc-alpha", - }, - "chat", - Any.Pack(new ChatRequestEvent { Prompt = "run workflow" }), - "rev-alpha")), - "0 9 * * *", - "UTC", - true, - new Dictionary(StringComparer.Ordinal), - ScheduledDispatchScheduleKind.Workflow) - { - CredentialRequirementTargetKind = ScheduledDispatchCredentialRequirementTargetKind.WorkflowService, - }; - private sealed class RecordingScopeWorkflowQueryPort : IScopeWorkflowQueryPort, IScopeWorkflowCatalogueCommittedSourcePort @@ -2942,238 +2775,241 @@ public Task LookupCatalogueByWorkflowIdAsync } } - private sealed class RecordingWorkflowEvidenceQueryPort : IScheduledInvocationWorkflowEvidenceQueryPort + private sealed class RecordingWorkflowCallerAccessTokenProvider : IWorkflowCallerAccessTokenProvider { - public Task GetAsync( - string scopeId, - string publishedServiceId, - string workflowRevisionId, - CancellationToken ct = default) => - Task.FromResult(new ScheduledInvocationWorkflowEvidence( - 5, - [new ExternalWorkflowCapabilityRef - { - NyxIdUserService = new NyxIdUserServiceCapabilityRef - { - UserServiceId = "nyx-service-alpha", - ServiceSlugSnapshot = "service-alpha", - }, - }], - OwnerLLMRouteRequired: false, - AuthorizationGrantRequirement.Required)); + public List Authorities { get; } = []; + + public Task IssueAsync( + CredentialWorkflowCallerAuthority authority, + CancellationToken ct = default) + { + Authorities.Add(authority.Clone()); + return Task.FromResult("issued-provisioning-token-alpha"); + } } - private sealed class RecordingExternalTriggerAuthorizationPlanner : IScheduledInvocationAuthorizationPlanner + private static DefaultHttpContext CreateExternalTriggerHttpContext( + RecordingWorkflowWebhookBindingStore bindingStore, + RecordingWorkflowWebhookAgentKeyMaterializer? materializer = null, + RecordingWorkflowCallerAccessTokenProvider? tokenProvider = null) { - public const string PermissionDigest = "permission-digest-alpha"; - public const string PolicyVersion = ScheduledInvocationAuthorizationContractVersions.CredentialPolicy; + var bindingReader = new FakeWorkflowActorBindingReader(); + bindingReader.Bindings["definition-actor-alpha"] = ExternalTriggerWorkflowBinding(); + return CreateHttpContext( + "scope-alpha", + configureServices: services => + { + services.AddSingleton(bindingStore); + services.AddSingleton(bindingReader); + services.AddSingleton(new FakeExternalIdentityBindingQueryPort()); + services.AddSingleton(tokenProvider ?? new RecordingWorkflowCallerAccessTokenProvider()); + services.AddSingleton(materializer ?? new RecordingWorkflowWebhookAgentKeyMaterializer()); + }); + } - public Task PlanAsync( - ScheduledInvocationAuthorizationRequest request, - CancellationToken ct = default) => - Task.FromResult(ScheduledInvocationAuthorizationPlanResult.Succeeded(CreatePlan())); + private static WorkflowActorBinding ExternalTriggerWorkflowBinding() + { + const string workflowYaml = "name: workflow-alpha\nsteps: []\n"; + var plan = ExternalTriggerDurableWritePlan(); + return new WorkflowActorBinding( + WorkflowActorKind.Definition, + "definition-actor-alpha", + "definition-actor-alpha", + string.Empty, + "workflow-alpha", + workflowYaml, + new Dictionary(), + ExternalCapabilityExecutionMode.Durable, + ScopeId: "scope-alpha", + SourceVersion: 1, + CapabilityAdmissionPlan: plan, + WorkflowId: "wf-alpha", + RevisionId: "rev-alpha"); + } - private static ScheduledInvocationAuthorizationPlan CreatePlan() + private static WorkflowCapabilityAdmissionPlan ExternalTriggerDurableWritePlan() + { + var request = new NyxIdRequestSelector { - var plan = new ScheduledInvocationAuthorizationPlan - { - PermissionDigest = PermissionDigest, - Owner = new AuthorizationOwnerIdentity - { - Authority = NyxIdAuthorizationAuthorities.NyxId, - OwnerKind = AuthorizationOwnerKind.Personal, - OwnerSubject = "nyx-owner-alpha", - }, - CredentialPolicy = new ScheduledInvocationCredentialPolicy - { - ServiceGrantRequirement = AuthorizationGrantRequirement.Required, - NodeGrantRequirement = AuthorizationGrantRequirement.Required, - ExpiresAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(24)), - PolicyVersion = PolicyVersion, - }, - CatalogAuthority = new NyxIdCatalogAuthorityStamp - { - ActorStateVersion = 13, - ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddMinutes(-10)), - FreshUntil = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(1)), - ContentDigest = "catalog-digest-alpha", - ContractVersion = "scope-plan-contract/v1", - PolicyVersion = "scope-plan-policy/v1", - EvaluatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddMinutes(-10)), - }, - }; - plan.CredentialPolicy.Scopes.Add(new[] { NyxIdCredentialScope.Read, NyxIdCredentialScope.Proxy }); - plan.NyxIdServiceGrants.Add(new NyxIdServiceGrant + UserServiceId = "service-alpha", + Method = NyxIdRequestMethod.Post, + PathTemplate = "/v1/resources", + BodyMode = NyxIdRequestBodyMode.Json, + BodyRequired = true, + ResponseMode = NyxIdRequestResponseMode.Text, + }; + var policy = new NyxIdOperationExecutionPolicy + { + Risk = NyxIdOperationRisk.Write, + Approval = NyxIdOperationApproval.Required, + EnforcementOwner = NyxIdOperationEnforcementOwner.Aevatar, + AllowedExecutionModes = { - UserServiceId = "nyx-service-alpha", - NodeGrantRequirement = AuthorizationGrantRequirement.Required, - NodeIds = { "nyx-node-alpha" }, - }); - plan.Disclosures.Add(new[] + ExternalCapabilityExecutionMode.Interactive, + ExternalCapabilityExecutionMode.Durable, + }, + }; + var requestDigest = WorkflowCapabilityAdmissionPlanIntegrity.ComputeNyxIdRequestContractDigest(request); + var grant = new NyxIdExplicitRequestGrant + { + WorkflowId = "wf-alpha", + RevisionId = "rev-alpha", + CallSiteId = "workflow-alpha/update_resource", + RequestContractDigest = requestDigest, + GrantorAuthority = NyxIdExplicitRequestGrantorAuthority.AevatarWorkflowBinder, + GrantorOwnerKind = ExternalCapabilityAuthorizationOwnerKind.Personal, + GrantorOwnerSubject = "caller-alpha", + Risk = NyxIdOperationRisk.Write, + AllowedExecutionModes = { - ScheduledInvocationDisclosure.DedicatedCredential, - ScheduledInvocationDisclosure.AevatarSecretCustody, - ScheduledInvocationDisclosure.BrowserNeverReceivesSecret, - ScheduledInvocationDisclosure.DeleteRevokesCredential, - ScheduledInvocationDisclosure.PauseResumePreservesCredential, - }); - plan.SourceStamps.Add(new[] + ExternalCapabilityExecutionMode.Interactive, + ExternalCapabilityExecutionMode.Durable, + }, + }; + var capability = new NyxIdUserRequestCapabilityRef + { + Request = request, + ServiceSlugSnapshot = "api-resource-service", + ContractDigest = WorkflowCapabilityAdmissionPlanIntegrity + .ComputeNyxIdExplicitRequestProofDigest(requestDigest, "api-resource-service"), + ExplicitRequestGrantDigest = WorkflowCapabilityAdmissionPlanIntegrity + .ComputeNyxIdExplicitRequestGrantDigest(grant), + ExecutionPolicy = policy, + }; + var plan = new WorkflowCapabilityAdmissionPlan + { + SchemaVersion = WorkflowCapabilityAdmissionPlanIntegrity.SchemaVersion, + DefinitionDigest = "sha256:definition", + ExecutionMode = ExternalCapabilityExecutionMode.Durable, + DurableAuthorizationOwner = new ExternalCapabilityAuthorizationOwner { - new AuthorizationSourceStamp - { - SourceKind = AuthorizationSourceKind.WorkflowRevision, - SourceId = "rev-alpha", - StateVersion = 5, - }, - new AuthorizationSourceStamp - { - SourceKind = AuthorizationSourceKind.ConnectorCatalog, - SourceId = "connector-alpha", - StateVersion = 7, - }, - }); - return plan; - } - } + Authority = WorkflowCapabilityAdmissionPlanIntegrity.NyxIdAuthority, + OwnerKind = ExternalCapabilityAuthorizationOwnerKind.Personal, + OwnerSubject = "caller-alpha", + }, + }; + plan.InvocationAdmissions.Add(new WorkflowCapabilityInvocationAdmission + { + CallSiteId = grant.CallSiteId, + Capability = new ExternalWorkflowCapabilityRef { NyxIdUserRequest = capability }, + NyxIdExplicitRequestGrant = grant, + }); + plan.AdmissionDigest = WorkflowCapabilityAdmissionPlanIntegrity.ComputeAdmissionDigest(plan); + return plan; + } + + private static WorkflowWebhookBindingRecord ExternalTriggerBindingRecord( + string routeKey, + string scopeId = "scope-alpha", + DurableCallerCredentialRef? callerDurableCredential = null) => new( + RouteKey: routeKey, + ScopeId: scopeId, + WorkflowName: "workflow-alpha", + SourceId: "nyxid-external-trigger", + PromptTemplate: "Run {{event_id}}", + PromptJsonPath: null, + DeliveryIdHeader: "X-NyxID-Delivery-Id", + DeliveryIdJsonPath: "event_id", + HmacSecret: "delivery-signing-secret-at-least-32-bytes", + HmacSignatureHeader: "X-NyxID-Signature", + HmacTimestampHeader: "X-NyxID-Timestamp", + MaxTimestampSkewSeconds: 300, + UpdatedAtUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + DefinitionActorId: "definition-actor-alpha", + TargetRevisionId: "rev-alpha", + CallerAuthority: callerDurableCredential == null ? null : new CredentialWorkflowCallerAuthority + { + Platform = OwnerScope.NyxIdPlatform, + ExternalUserId = "caller-alpha", + Scope = "proxy", + BindingId = "binding-caller-alpha", + }, + CallerDurableCredential: callerDurableCredential); - private sealed class RecordingExternalTriggerAuthorizationRevalidator( - IScheduledInvocationAuthorizationPlanner planner) : IScheduledInvocationAuthorizationRevalidator + private sealed class RecordingWorkflowWebhookBindingStore : IWorkflowWebhookBindingStore { - public async Task RevalidateAsync( - ScheduledInvocationAuthorizationRequest request, - ScheduledInvocationAuthorizationConfirmation confirmation, + public Dictionary Records { get; } = new(StringComparer.Ordinal); + + public Task GetAsync(string routeKey, CancellationToken ct = default) => + Task.FromResult(Records.GetValueOrDefault(routeKey)); + + public async Task TryPutOwnedAsync(WorkflowWebhookBindingRecord record, CancellationToken ct = default) => + (await PutOwnedAsync(record, ct)).Succeeded; + + public Task PutOwnedAsync( + WorkflowWebhookBindingRecord record, CancellationToken ct = default) { - var result = await planner.PlanAsync(request, ct); - if (!result.Success) + if (Records.TryGetValue(record.RouteKey, out var existing) && + !string.Equals(existing.ScopeId, record.ScopeId, StringComparison.Ordinal)) { - return ScheduledInvocationAuthorizationValidationResult.Failed( - ScheduledInvocationAuthorizationFailureCode.AuthorizationPlanChanged, - result.Detail); + return Task.FromResult(new WorkflowWebhookBindingPutResult(false, null)); } - return SuccessfulValidation(result.Plan!); - } - - private static ScheduledInvocationAuthorizationValidationResult SuccessfulValidation( - ScheduledInvocationAuthorizationPlan plan) - { - var constructor = typeof(ValidatedScheduledInvocationAuthorizationPlan) - .GetConstructor( - BindingFlags.Instance | BindingFlags.NonPublic, - binder: null, - [typeof(ScheduledInvocationAuthorizationPlan)], - modifiers: null) ?? throw new InvalidOperationException("validated_plan_constructor_missing"); - var validatedPlan = (ValidatedScheduledInvocationAuthorizationPlan)constructor.Invoke([plan]); - return new ScheduledInvocationAuthorizationValidationResult( - validatedPlan, - ScheduledInvocationAuthorizationFailureCode.Unspecified, - string.Empty, - ObservedCatalogStateVersion: plan.CatalogAuthority?.ActorStateVersion ?? 0); + Records[record.RouteKey] = record; + return Task.FromResult(new WorkflowWebhookBindingPutResult(true, existing)); } - } - private sealed class RecordingExternalTriggerCredentialMaterializer( - Exception? exception = null) : IStudioScheduledCredentialMaterializer - { - public int MaterializeCallCount { get; private set; } - public string? BearerToken { get; private set; } + public async Task TryDeleteOwnedAsync( + string routeKey, + string scopeId, + CancellationToken ct = default) => + (await DeleteOwnedAsync(routeKey, scopeId, ct)).Succeeded; - public ScheduledCredentialEffectLocator CreateEffectLocator( - string scheduleId, - string operationId, - ScheduledInvocationAuthorizationOwner credentialOwner) => - new( - $"credential-{scheduleId}-{operationId}", - $"secret-{scheduleId}-{operationId}", - CredentialSecretPurposes.ScheduledInvocationAgentKey, - $"schedule:{scheduleId}", - credentialOwner); - - public Task MaterializeAsync( - string bearerToken, - ValidatedScheduledInvocationAuthorizationPlan validatedPlan, - string scheduleId, - string operationId, - ScheduledCredentialEffectLocator effectLocator, - StudioScheduledCredentialMaterializationMode mode, - Aevatar.Foundation.Abstractions.OwnerScope ownerScope, + public Task DeleteOwnedAsync( + string routeKey, + string scopeId, CancellationToken ct = default) { - MaterializeCallCount++; - BearerToken = bearerToken; - if (exception != null) - return Task.FromException(exception); + if (!Records.TryGetValue(routeKey, out var existing) || + !string.Equals(existing.ScopeId, scopeId, StringComparison.Ordinal)) + { + return Task.FromResult(new WorkflowWebhookBindingDeleteResult(false, null)); + } - var expiresAt = validatedPlan.Plan.CredentialPolicy.ExpiresAt.ToDateTimeOffset().AddMinutes(-1); - return Task.FromResult(new StudioScheduledCredential( - "agent-key-alpha", - new SecretReference - { - Ref = "secret-alpha", - Purpose = CredentialSecretPurposes.ScheduledInvocationAgentKey, - OwnerScopeKey = "schedule:test", - ExpiresAtUnixMs = expiresAt.ToUnixTimeMilliseconds(), - }, - expiresAt, - new ScheduledInvocationAuthorizationOwner("nyxid", "Personal", "nyx-owner-alpha"))); + Records.Remove(routeKey); + return Task.FromResult(new WorkflowWebhookBindingDeleteResult(true, existing)); } - public Task RevokeAsync( - string bearerToken, - AuthenticatedAuthorizationOwnerContext authenticatedOwner, - StudioScheduledCredential credential, - bool revokeNyxId, - bool revokeVault, + public Task> ListByScopeAsync( + string scopeId, CancellationToken ct = default) => - Task.FromResult(new StudioScheduledCredentialRevocationResult(true, true, string.Empty)); + Task.FromResult>( + Records.Values.Where(record => string.Equals(record.ScopeId, scopeId, StringComparison.Ordinal)).ToArray()); } - private sealed class RecordingWorkflowCallerAccessTokenProvider : IWorkflowCallerAccessTokenProvider + private sealed class RecordingWorkflowWebhookAgentKeyMaterializer : IWorkflowWebhookAgentKeyMaterializer { - public List Authorities { get; } = []; + public List<(CredentialWorkflowCallerAuthority Authority, string ScopeId, string RouteKey)> Materialized { get; } = []; + public List<(CredentialWorkflowCallerAuthority? Authority, DurableCallerCredentialRef Credential, string AuditReason)> Revoked { get; } = []; - public Task IssueAsync( - CredentialWorkflowCallerAuthority authority, - CancellationToken ct = default) + public Task MaterializeAsync( + CredentialWorkflowCallerAuthority callerAuthority, + WorkflowCapabilityAdmissionPlan admissionPlan, + string scopeId, + string routeKey, + CancellationToken ct) { - Authorities.Add(authority.Clone()); - return Task.FromResult("issued-provisioning-token"); + Materialized.Add((callerAuthority.Clone(), scopeId, routeKey)); + return Task.FromResult(WorkflowWebhookAgentKeyMaterializationResult.Success(new DurableCallerCredentialRef + { + Ref = $"webhook-agent-key-ref-{Materialized.Count}", + Purpose = CredentialSecretPurposes.WorkflowWebhookBindingAgentKey, + OwnerScopeKey = scopeId, + SubjectId = callerAuthority.ExternalUserId, + SourceKind = DurableCallerCredentialSourceKind.WebhookBinding, + ProviderCredentialId = $"provider-key-{Materialized.Count}", + })); } - } - private sealed class RecordingWorkflowExternalTriggerProvisioningPort : IWorkflowExternalTriggerProvisioningPort - { - public List Workflows { get; } = []; - public List Configurations { get; } = []; - public List Contexts { get; } = []; - public List Authorities { get; } = []; - - public Task ProvisionAsync( - ScopeWorkflowSummary workflow, - ScheduledDispatchConfiguration configuration, - ScheduledDispatchMutationContext context, - StudioMemberAutomationHttpAuthority authority, - CancellationToken ct = default) + public Task RevokeAsync( + CredentialWorkflowCallerAuthority? callerAuthority, + DurableCallerCredentialRef credential, + string auditReason, + CancellationToken ct) { - Workflows.Add(workflow); - Configurations.Add(configuration); - Contexts.Add(context); - Authorities.Add(authority); - return Task.FromResult(new WorkflowExternalTriggerProvisioningResult( - new ScheduledDispatchMutationReceipt( - configuration.ScheduleId, - $"actor:{configuration.ScheduleId}", - true, - "cmd-external-trigger", - "corr-external-trigger", - DateTimeOffset.UtcNow, - "accepted"), - configuration, - ScheduledDispatchCredentialSourceKind.ScheduledInvocationAgentKey, - DateTimeOffset.UtcNow.AddDays(30), - "permission-digest-alpha", - ScheduledInvocationAuthorizationContractVersions.CredentialPolicy)); + Revoked.Add((callerAuthority?.Clone(), credential.Clone(), auditReason)); + return Task.FromResult(true); } }