using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
///
/// GlobalAdmin-only management of delegated admin assignments — the "scoped global" / "MSP mode" tier
/// that lets a UPN READ a subset of tenants. Mirrors the MCP-user management surface (list / grant / revoke /
/// enable / disable). The platform operator (GlobalAdmin) grants centrally;
/// is stamped as the source. Reads are GlobalReadOrAdmin (a read-only Global Reader can audit who is delegated);
/// all mutations are GlobalAdminOnly (enforced by PolicyEnforcementMiddleware via the route catalog).
///
/// Mutations are audited under the target managed tenant's trail (not the caller's home tenant), so the
/// customer can see "who was granted access to my tenant" alongside their own admin changes.
///
public class DelegatedAdminManagementFunction
{
private readonly ILogger _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public DelegatedAdminManagementFunction(
ILogger logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin.
[Function("GetDelegatedAdmins")]
[Authorize]
public async Task GetDelegatedAdmins(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/delegated-admins")] HttpRequestData req)
{
var assignments = await _delegatedAdminService.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { assignments });
return response;
}
///
/// POST /api/global/delegated-admins — grant (create/replace) an assignment. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "tenantId": "<guid>", "role": "DelegatedReader" | "DelegatedAdmin" }.
///
[Function("GrantDelegatedAdmin")]
[Authorize]
public async Task GrantDelegatedAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/delegated-admins")] HttpRequestData req,
FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync();
var validationError = ValidateGrantRequest(body, out var role);
if (validationError != null)
return await Bad(req, validationError);
// The UPN's identity binding (home tenant + object id) is resolved automatically — sign-in history,
// then UPN domain → onboarded tenant; the body may override it. Unresolvable ⇒ 422 and the UI asks
// the operator to pick the tenant. Edition note: TARGET tenants may be any edition — an MSP on Pro
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
return await Unresolved(req);
DelegatedAdminEntry entry;
try
{
entry = await _delegatedAdminService.UpsertAsync(
body.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "",
identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
return await Conflict(req, ex.Message);
}
await _maintenanceRepo.LogAuditEntryAsync(
entry.TenantId, "CREATE", "DelegatedAdmin", entry.Upn, currentUpn ?? "",
new Dictionary
{
{ "Role", entry.Role },
{ "Status", entry.Status },
{ "Source", entry.Source },
{ "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() },
});
_logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}",
entry.Upn, entry.TenantId, entry.Role, currentUpn);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { assignment = entry });
return response;
}
/// DELETE /api/global/delegated-admins/{upn}/{tenantId} — revoke (remove) an assignment. GlobalAdminOnly.
[Function("RevokeDelegatedAdmin")]
[Authorize]
public async Task RevokeDelegatedAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/delegated-admins/{upn}/{tenantId}")] HttpRequestData req,
string upn, string tenantId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var removed = await RevokeCoreAsync(upn, tenantId, currentUpn);
if (!removed)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Delegated assignment not found" });
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Delegated assignment revoked" });
return response;
}
///
/// Testable core of the revoke flow (no HTTP plumbing — see GetAllBlockedDevicesFunctionTests for the
/// convention). Returns false when no assignment row existed (caller 404s): a mistyped upn/tenantId
/// must not 200 and write a false customer-visible "access removed" audit row while the real grant
/// stays live (mirrors the tenant-group revoke semantics). On a real removal writes the audit entry
/// and cuts the revoked UPN's live streams — group authorization is join-time only, so without the
/// kick they keep receiving tenant telemetry until the connection drops.
///
internal async Task RevokeCoreAsync(string upn, string tenantId, string? currentUpn)
{
var removed = await _delegatedAdminService.RemoveAsync(upn, tenantId);
if (!removed)
return false;
await _maintenanceRepo.LogAuditEntryAsync(
tenantId.ToLowerInvariant(), "DELETE", "DelegatedAdmin", upn.ToLowerInvariant(), currentUpn ?? "");
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
_logger.LogInformation("Delegated revoke: {Upn} -> {TenantId} by {By}", upn, tenantId, currentUpn);
return true;
}
/// PATCH /api/global/delegated-admins/{upn}/{tenantId}/enable — re-enable a disabled assignment. GlobalAdminOnly.
[Function("EnableDelegatedAdmin")]
[Authorize]
public Task EnableDelegatedAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/delegated-admins/{upn}/{tenantId}/enable")] HttpRequestData req,
string upn, string tenantId, FunctionContext context)
=> SetEnabled(req, upn, tenantId, true, context);
/// PATCH /api/global/delegated-admins/{upn}/{tenantId}/disable — disable without removing. GlobalAdminOnly.
[Function("DisableDelegatedAdmin")]
[Authorize]
public Task DisableDelegatedAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/delegated-admins/{upn}/{tenantId}/disable")] HttpRequestData req,
string upn, string tenantId, FunctionContext context)
=> SetEnabled(req, upn, tenantId, false, context);
private async Task SetEnabled(
HttpRequestData req, string upn, string tenantId, bool isEnabled, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var ok = await SetEnabledCoreAsync(upn, tenantId, isEnabled, currentUpn);
if (!ok)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Delegated assignment not found" });
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = isEnabled ? "Delegated assignment enabled" : "Delegated assignment disabled" });
return response;
}
///
/// Testable core of enable/disable (no HTTP plumbing). Returns false when no assignment row
/// existed (caller 404s — no audit, no disconnect). Disable is a revocation too, so it cuts the
/// UPN's already-joined live streams (see ); enable never disconnects.
///
internal async Task SetEnabledCoreAsync(string upn, string tenantId, bool isEnabled, string? currentUpn)
{
var ok = await _delegatedAdminService.SetEnabledAsync(upn, tenantId, isEnabled);
if (!ok)
return false;
await _maintenanceRepo.LogAuditEntryAsync(
tenantId.ToLowerInvariant(), "UPDATE", "DelegatedAdmin", upn.ToLowerInvariant(), currentUpn ?? "",
new Dictionary { { "IsEnabled", isEnabled.ToString() } });
if (!isEnabled)
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
_logger.LogInformation("Delegated {Action}: {Upn} -> {TenantId} by {By}",
isEnabled ? "enable" : "disable", upn, tenantId, currentUpn);
return true;
}
///
/// Pure grant-request validation (testable seam). Returns the 400 error message, or null when valid —
/// then carries the effective role. A mistyped tenantId must not create a
/// garbage scope entry that pollutes the allow-list and seeds a per-tenant audit trail keyed on the
/// typo; the role defaults to least-privileged and unknown roles are rejected (fail-closed) so a typo
/// can never silently widen access.
///
internal static string? ValidateGrantRequest(GrantDelegatedAdminRequest? body, out string role)
{
role = Constants.DelegatedRoles.DelegatedReader;
if (body == null || string.IsNullOrWhiteSpace(body.Upn) || string.IsNullOrWhiteSpace(body.TenantId))
return "upn and tenantId are required";
if (!Guid.TryParse(body.TenantId, out _))
return "a valid tenantId (GUID) is required";
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return bindingError;
role = string.IsNullOrWhiteSpace(body.Role) ? Constants.DelegatedRoles.DelegatedReader : body.Role!;
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'";
return null;
}
private static async Task Bad(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
private static async Task Conflict(HttpRequestData req, string error)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { error });
return conflict;
}
private static async Task Unresolved(HttpRequestData req)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
}
public class GrantDelegatedAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// The MANAGED (target) tenant the UPN may read.
public string TenantId { get; set; } = string.Empty;
public string? Role { get; set; }
/// The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.
public string? HomeTenantId { get; set; }
/// The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.
public string? ObjectId { get; set; }
}