feat: complete task board workflow gates
This commit is contained in:
@@ -16,6 +16,8 @@ public class DashboardController(
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||
{
|
||||
@@ -191,9 +193,15 @@ public class DashboardController(
|
||||
|
||||
// ── Task Board Endpoints ──
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("tasks/board")]
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||
{
|
||||
if (!await CanReadBoardAsync(ct))
|
||||
return Unauthorized();
|
||||
|
||||
return Ok(await taskService.GetBoardAsync(ct));
|
||||
}
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task Live(
|
||||
@@ -320,8 +328,17 @@ public class DashboardController(
|
||||
[HttpGet("tasks/{id:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||
{
|
||||
var children = await taskService.GetChildTasksAsync(id, ct);
|
||||
return Ok(children.Select(MapToDto).ToList());
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
var children = board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done)
|
||||
.Where(task => task.ParentTaskId == id)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(children);
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
@@ -401,7 +418,7 @@ public class DashboardController(
|
||||
var task = await taskService.CreateAgentTaskAsync(
|
||||
request.Title, request.Detail, request.Source ?? "iris",
|
||||
request.Priority, request.AssignedTo, request.ExpectedFrom,
|
||||
request.ParentTaskId, ct);
|
||||
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
|
||||
|
||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||
}
|
||||
@@ -415,4 +432,16 @@ public class DashboardController(
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
t.IsAgentTask, t.ExpectedFrom);
|
||||
|
||||
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
|
||||
{
|
||||
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
|
||||
if (!string.IsNullOrWhiteSpace(allowedAgent))
|
||||
return true;
|
||||
|
||||
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
|
||||
return true;
|
||||
|
||||
return User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers;
|
||||
public class GatewayBridgeController(
|
||||
ITaskBridgeService bridge,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||
{
|
||||
private const string ApikeyErrorMessage =
|
||||
@@ -101,6 +102,7 @@ public class GatewayBridgeController(
|
||||
priority: command.Priority ?? "Normal",
|
||||
assignedTo: command.AssignedTo,
|
||||
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||
startsInProgress: command.StartsInProgress,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "create_child_task");
|
||||
@@ -232,12 +234,13 @@ public class GatewayBridgeController(
|
||||
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
||||
{
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
|
||||
|
||||
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
{
|
||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||
if (allowedAgentIds.Contains(normalizedHeader))
|
||||
if (allowedActorIds.Contains(normalizedHeader))
|
||||
return (true, normalizedHeader, null);
|
||||
|
||||
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||
@@ -248,14 +251,17 @@ public class GatewayBridgeController(
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
|
||||
return (true, normalizedClaim, null);
|
||||
|
||||
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
|
||||
// Browser JWT fallback is intentionally restricted to board owners/admins.
|
||||
// Agent/service traffic should authenticate as an allowed agent or service principal.
|
||||
if (User.IsInRole("owner") || User.IsInRole("admin"))
|
||||
return (true, "bao", null);
|
||||
}
|
||||
|
||||
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
|
||||
if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
|
||||
allowedActorIds.Contains("nexus-system"))
|
||||
return (true, "nexus-system", null);
|
||||
|
||||
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||
@@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand(
|
||||
string? Detail = null,
|
||||
string? Priority = null,
|
||||
string? AssignedTo = null,
|
||||
string? ExpectedFrom = null
|
||||
string? ExpectedFrom = null,
|
||||
bool StartsInProgress = false
|
||||
);
|
||||
|
||||
public sealed record BridgeUpdateStatusCommand(string State);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Nexus.Api.Controllers;
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
@@ -117,12 +117,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("board")]
|
||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||
{
|
||||
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
|
||||
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
||||
@@ -136,33 +136,28 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
///
|
||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||
/// X-Nexus-Api-Key / JWT-authenticated user.
|
||||
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("reset-stale")]
|
||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
|
||||
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
|
||||
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
|
||||
|
||||
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isIris && !isService && !isPrivilegedUser)
|
||||
{
|
||||
// A presented but unrecognized agent header is an invalid credential, not a missing one.
|
||||
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
|
||||
return Results.Forbid();
|
||||
|
||||
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
|
||||
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isIris && !isApiKey && !isAuth)
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||
return Results.Ok(new ResetStaleResponse(count));
|
||||
}
|
||||
|
||||
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
|
||||
{
|
||||
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue))
|
||||
return null;
|
||||
|
||||
var normalized = headerValue.Trim().ToLowerInvariant();
|
||||
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
return allowed.Contains(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user