mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
98d87a8579
The root .editorconfig raises IDE0051, IDE0052 and IDE0060 (non-public methods) to warning, and Directory.Build.props sets EnforceCodeStyleInBuild, so the warning freeze turns them into build errors in every project, the net48 agent included. Cleanup to start at zero, each finding checked for leftover vs. missing behaviour (none was a feature that silently never ran): - 38 injected dependencies nobody read: loggers, configuration, and services whose last read had moved to a live replacement (the IME reseed writes through the rule repository, the offboarding poison OpsEvent comes from the handler, the vulnerability gate sits in the correlate handler). Removed with their constructor parameters and the argument at every construction site. - unused private members: SelfUpdater.IsNewerVersion, a GatherRuleExecutor wrapper, test constants and an unused test overload. RjPhaseCompletedFirstDeployment now replaces the two literal 110s it names. - unused parameters removed together with their arguments; test fakes bound as method groups use discards. - the commented-out backend-URL connectivity check and its plumbing are gone; the reason the backend stays out remains as a comment. - DiagnosticsPackageService names the blob in its upload-failure warnings, as its doc already said. - stale docs fixed on the way: OnboardedAt no longer claims a maintenance backfill (shared manifest and wire types regenerated), the vulnerability functions' policy comments match the catalog, a misplaced summary moved to its method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
12 KiB
C#
262 lines
12 KiB
C#
using System.Net;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Admin;
|
|
|
|
/// <summary>
|
|
/// HTTP surface of self-service delegation for the MANAGING (Pro / MSP) tenant, plus the accept endpoints a
|
|
/// customer's tenant admin uses. Every route acts on the caller's JWT tenant (no <c>{tenantId}</c> in the
|
|
/// templates — the cross-tenant guard would otherwise deny a managing tenant acting on a customer); the
|
|
/// core logic and every audit/hold rule live in <see cref="DelegationSelfService"/>.
|
|
/// </summary>
|
|
public class DelegationSelfServiceFunction
|
|
{
|
|
private readonly DelegationSelfService _svc;
|
|
private readonly DelegatedSlotService _slots;
|
|
|
|
public DelegationSelfServiceFunction(
|
|
DelegationSelfService svc,
|
|
DelegatedSlotService slots)
|
|
{
|
|
_svc = svc;
|
|
_slots = slots;
|
|
}
|
|
|
|
// ── Slots + managed tenants ──────────────────────────────────────────────────
|
|
|
|
/// <summary>GET /api/delegations/slots — the caller's tenant's slot usage. TenantAdminOrGlobalReader.</summary>
|
|
[Function("GetDelegationSlots")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> GetSlots(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/slots")] HttpRequestData req)
|
|
{
|
|
var usage = await _slots.GetUsageAsync(req.GetRequestContext().TenantId);
|
|
return await req.OkAsync(DelegatedSlotManagementFunction.ToResponse(usage));
|
|
}
|
|
|
|
/// <summary>GET /api/delegations/managed — the tenants the caller's tenant manages.</summary>
|
|
[Function("GetDelegationManagedTenants")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> GetManaged(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/managed")] HttpRequestData req)
|
|
{
|
|
var home = req.GetRequestContext().TenantId;
|
|
var view = await _svc.ListManagedAsync(home);
|
|
|
|
var items = new List<ManagedTenantItem>(view.Tenants.Count);
|
|
foreach (var t in view.Tenants)
|
|
{
|
|
items.Add(new ManagedTenantItem
|
|
{
|
|
TenantId = t.TenantId,
|
|
Domain = await _svc.DomainAsync(t.TenantId),
|
|
Source = t.Source,
|
|
SinceUtc = t.SinceUtc,
|
|
Removable = t.Removable,
|
|
});
|
|
}
|
|
|
|
return await req.OkAsync(new ManagedTenantListResponse
|
|
{
|
|
HomeTenantId = home,
|
|
Slots = DelegatedSlotManagementFunction.ToResponse(view.Slots),
|
|
Tenants = items,
|
|
});
|
|
}
|
|
|
|
/// <summary>POST /api/delegations/managed/remove — end a self-service delegation to one managed tenant (24 h slot hold). TenantAdminOrGA.</summary>
|
|
[Function("RemoveDelegationManagedTenant")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> RemoveManaged(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/managed/remove")] HttpRequestData req)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var read = await req.ReadAsync<RemoveManagedTenantRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
if (!Guid.TryParse(body.TenantId, out _))
|
|
return await BadAsync(req, "a valid tenantId (GUID) is required");
|
|
|
|
var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName);
|
|
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Delegation ended" }) : await FailAsync(req, result.Failure!);
|
|
}
|
|
|
|
// ── Invitations ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>GET /api/delegations/invitations — every invitation of the caller's tenant (never the token).</summary>
|
|
[Function("GetDelegationInvitations")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> GetInvitations(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/invitations")] HttpRequestData req)
|
|
{
|
|
var home = req.GetRequestContext().TenantId;
|
|
var rows = await _svc.ListInvitationsAsync(home);
|
|
var items = new List<DelegationInvitationItem>(rows.Count);
|
|
foreach (var r in rows.OrderByDescending(r => r.CreatedAt))
|
|
{
|
|
items.Add(new DelegationInvitationItem
|
|
{
|
|
InvitationId = r.InvitationId,
|
|
Status = _svc.EffectiveStatus(r),
|
|
CreatedBy = r.CreatedBy,
|
|
CreatedUtc = r.CreatedAt,
|
|
ExpiresUtc = r.ExpiresAt,
|
|
AcceptedUtc = r.AcceptedAt,
|
|
AcceptedBy = r.AcceptedBy,
|
|
TenantId = r.TenantId,
|
|
TenantDomain = string.IsNullOrEmpty(r.TenantId) ? null : await _svc.DomainAsync(r.TenantId),
|
|
HoldUntilUtc = r.HoldUntilUtc,
|
|
});
|
|
}
|
|
return await req.OkAsync(new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items });
|
|
}
|
|
|
|
/// <summary>POST /api/delegations/invitations — mint a single-use invitation link (token shown once). TenantAdminOrGA.</summary>
|
|
[Function("CreateDelegationInvitation")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> CreateInvitation(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/invitations")] HttpRequestData req)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var result = await _svc.CreateInvitationAsync(ctx.TenantId, ctx.UserPrincipalName);
|
|
if (!result.Ok)
|
|
return await FailAsync(req, result.Failure!);
|
|
|
|
var (row, token) = result.Value;
|
|
var response = req.CreateResponse(HttpStatusCode.Created);
|
|
await response.WriteAsJsonAsync(new CreateDelegationInvitationResponse { InvitationId = row.InvitationId, Token = token, ExpiresUtc = row.ExpiresAt });
|
|
return response;
|
|
}
|
|
|
|
/// <summary>DELETE /api/delegations/invitations/{invitationId} — cancel a pending invitation (frees its slot at once).</summary>
|
|
[Function("CancelDelegationInvitation")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> CancelInvitation(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "delegations/invitations/{invitationId}")] HttpRequestData req,
|
|
string invitationId)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var result = await _svc.CancelInvitationAsync(ctx.TenantId, invitationId, ctx.UserPrincipalName);
|
|
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!);
|
|
}
|
|
|
|
// ── Assignees (the managing tenant's own users) ──────────────────────────────
|
|
|
|
/// <summary>GET /api/delegations/assignees — who in the caller's tenant may read the managed tenants.</summary>
|
|
[Function("GetDelegationAssignees")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> GetAssignees(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/assignees")] HttpRequestData req)
|
|
{
|
|
var home = req.GetRequestContext().TenantId;
|
|
var assignees = await _svc.ListAssigneesAsync(home);
|
|
return await req.OkAsync(new DelegationAssigneeListResponse
|
|
{
|
|
HomeTenantId = home,
|
|
GroupId = Constants.TenantGroupIds.ForHomeTenant(home),
|
|
Assignees = assignees.OrderBy(a => a.Upn, StringComparer.Ordinal).ToList(),
|
|
});
|
|
}
|
|
|
|
/// <summary>POST /api/delegations/assignees — assign one of the caller's tenant members (read-only). TenantAdminOrGA.</summary>
|
|
[Function("AssignDelegationAssignee")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> Assign(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/assignees")] HttpRequestData req)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var read = await req.ReadAsync<DelegationAssignRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
if (string.IsNullOrWhiteSpace(body.Upn))
|
|
return await BadAsync(req, "upn is required");
|
|
|
|
// The member key as listed under Access Management — a UPN or a service principal's app:<client-id>
|
|
// key; the service verifies it is a member of the caller's tenant, which is the only grant path.
|
|
var result = await _svc.AssignAsync(ctx.TenantId, body.Upn, ctx.UserPrincipalName);
|
|
if (!result.Ok)
|
|
return await FailAsync(req, result.Failure!);
|
|
var response = req.CreateResponse(HttpStatusCode.Created);
|
|
await response.WriteAsJsonAsync(new DelegationAssignResponse { Assignment = result.Value! });
|
|
return response;
|
|
}
|
|
|
|
/// <summary>DELETE /api/delegations/assignees/{upn} — unassign. TenantAdminOrGA.</summary>
|
|
[Function("UnassignDelegationAssignee")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> Unassign(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "delegations/assignees/{upn}")] HttpRequestData req,
|
|
string upn)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var result = await _svc.UnassignAsync(ctx.TenantId, upn, ctx.UserPrincipalName);
|
|
return result.Ok ? await req.OkAsync(new MessageResponse { Message = "Unassigned" }) : await FailAsync(req, result.Failure!);
|
|
}
|
|
|
|
// ── Accept (the customer's tenant admin) ─────────────────────────────────────
|
|
|
|
/// <summary>GET /api/delegations/accept?token= — what accepting would do; no mutation. TenantAdminOrGA.</summary>
|
|
[Function("PreviewDelegationInvitation")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> Preview(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/accept")] HttpRequestData req)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var token = req.Query["token"] ?? string.Empty;
|
|
var result = await _svc.PreviewAsync(token, ctx.TenantId);
|
|
if (!result.Ok)
|
|
return await FailAsync(req, result.Failure!);
|
|
var p = result.Value!;
|
|
return await req.OkAsync(new DelegationAcceptPreviewResponse
|
|
{
|
|
HomeTenantId = p.HomeTenantId,
|
|
HomeTenantDomain = p.HomeTenantDomain,
|
|
ExpiresUtc = p.ExpiresUtc,
|
|
Status = p.Status,
|
|
TargetTenantId = p.TargetTenantId,
|
|
TargetTenantDomain = p.TargetTenantDomain,
|
|
});
|
|
}
|
|
|
|
/// <summary>POST /api/delegations/accept { token } — the caller's tenant joins the inviting tenant's managed set. TenantAdminOrGA.</summary>
|
|
[Function("AcceptDelegationInvitation")]
|
|
[Authorize]
|
|
public async Task<HttpResponseData> Accept(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/accept")] HttpRequestData req)
|
|
{
|
|
var ctx = req.GetRequestContext();
|
|
var read = await req.ReadAsync<AcceptDelegationInvitationRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
if (string.IsNullOrWhiteSpace(body.Token))
|
|
return await BadAsync(req, "token is required");
|
|
|
|
var result = await _svc.AcceptAsync(body.Token, ctx.TenantId, ctx.UserPrincipalName);
|
|
if (!result.Ok)
|
|
return await FailAsync(req, result.Failure!);
|
|
var o = result.Value!;
|
|
return await req.OkAsync(new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId });
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
internal static async Task<HttpResponseData> FailAsync(HttpRequestData req, DelegationFailure failure)
|
|
{
|
|
if (failure.SlotViolation != null)
|
|
return await DelegatedSlotResponses.ConflictAsync(req, failure.SlotViolation);
|
|
return await req.ErrorAsync((HttpStatusCode)failure.Status, failure.Code, failure.Message);
|
|
}
|
|
|
|
private static async Task<HttpResponseData> BadAsync(HttpRequestData req, string error)
|
|
{
|
|
return await req.BadRequestAsync(error);
|
|
}
|
|
}
|