using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.ApplicationInsights; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace AutopilotMonitor.Functions.Functions.Sessions { /// /// Queues a for delivery to the agent via the next ingest call. /// Used for manual admin-triggered actions (e.g. "force rotate config", "request diagnostics"). /// The RuleEngine queues its own actions directly via the repository. /// public class QueueSessionActionFunction { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepo; private readonly TelemetryClient _telemetryClient; private static readonly HashSet AllowedTypes = new(StringComparer.OrdinalIgnoreCase) { ServerActionTypes.TerminateSession, ServerActionTypes.RotateConfig, ServerActionTypes.RequestDiagnostics }; /// /// Action types an Operator may queue. The route is TenantAdminOrOperator so troubleshooting /// staff can trigger an on-demand diagnostics collection; everything with config or lifecycle /// impact (terminate_session, rotate_config) stays Admin/GA-only via . /// private static readonly HashSet OperatorAllowedTypes = new(StringComparer.OrdinalIgnoreCase) { ServerActionTypes.RequestDiagnostics }; /// /// Type-level authorization on top of the route policy: Admins and Global Admins may queue /// every allowed type, Operators only the types in . /// Internal-static so tests can pin the matrix without an HTTP host. /// internal static bool IsTypeAllowedForCaller(string actionType, bool isTenantAdmin, bool isGlobalAdmin) { if (isTenantAdmin || isGlobalAdmin) return true; return OperatorAllowedTypes.Contains(actionType); } public QueueSessionActionFunction( ILogger logger, ISessionRepository sessionRepo, TelemetryClient telemetryClient) { _logger = logger; _sessionRepo = sessionRepo; _telemetryClient = telemetryClient; } /// /// POST /api/sessions/{sessionId}/actions — queue an action for the given session. /// Body: { "type": "rotate_config", "reason": "...", "params": { "key": "value" } } /// [Function("QueueSessionAction")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/{sessionId}/actions")] HttpRequestData req, string sessionId) { try { // Authentication + TenantAdminOrOperator authorization enforced by // PolicyEnforcementMiddleware; per-type re-gating for Operators happens below. var tenantId = TenantHelper.GetTenantId(req); var userIdentifier = TenantHelper.GetUserIdentifier(req); var requestCtx = req.GetRequestContext(); string body; using (var reader = new StreamReader(req.Body)) body = await reader.ReadToEndAsync(); ServerAction? action; try { action = JsonConvert.DeserializeObject(body); } catch (Exception ex) { _logger.LogWarning(ex, "Invalid action payload for session {SessionId}", sessionId); return await BadRequestAsync(req, "Invalid JSON body"); } if (action == null || string.IsNullOrWhiteSpace(action.Type)) return await BadRequestAsync(req, "Action 'type' is required"); if (!AllowedTypes.Contains(action.Type)) return await BadRequestAsync(req, $"Unknown action type '{action.Type}'. Allowed: {string.Join(", ", AllowedTypes)}"); if (!IsTypeAllowedForCaller(action.Type, requestCtx.IsTenantAdmin, requestCtx.IsGlobalAdmin)) { _logger.LogWarning( "Operator {User} denied queueing admin-only action '{Type}' for session {SessionId}", userIdentifier, action.Type, sessionId); var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); await forbidden.WriteAsJsonAsync(new { success = false, message = $"Action type '{action.Type}' requires the Tenant Admin role" }); return forbidden; } // Stamp server-side fields — never trust client timestamps, and never let the caller // forge a RuleId (that's reserved for the RuleEngine). action.QueuedAt = DateTime.UtcNow; action.RuleId = null; if (string.IsNullOrWhiteSpace(action.Reason)) action.Reason = $"Manual action queued by {userIdentifier}"; var success = await _sessionRepo.QueueServerActionAsync(tenantId, sessionId, action); if (!success) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); await notFound.WriteAsJsonAsync(new { success = false, message = $"Session {sessionId} not found for tenant" }); return notFound; } _telemetryClient.TrackEvent("ServerActionQueued", new Dictionary { { "tenantId", tenantId }, { "sessionId", sessionId }, { "actionType", action.Type }, { "source", "admin_endpoint" }, { "userIdentifier", userIdentifier ?? string.Empty }, { "reason", action.Reason ?? string.Empty } }); _logger.LogInformation("Queued server action '{Type}' for session {SessionId} by {User}", action.Type, sessionId, userIdentifier); var response = req.CreateResponse(HttpStatusCode.Accepted); await response.WriteAsJsonAsync(new { success = true, message = $"Action '{action.Type}' queued for delivery", queuedAt = action.QueuedAt }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error queueing server action for session {SessionId}", sessionId); var response = req.CreateResponse(HttpStatusCode.InternalServerError); await response.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); return response; } } private static async Task BadRequestAsync(HttpRequestData req, string message) { var response = req.CreateResponse(HttpStatusCode.BadRequest); await response.WriteAsJsonAsync(new { success = false, message }); return response; } } }