mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Merge pull request #69 from okieselbach/feat/config-all-fields-projection
feat(backend,mcp): server-side fields= projection for paginated config/all
This commit is contained in:
@@ -36,11 +36,20 @@ public class TenantConfigPaginationTests
|
||||
{
|
||||
{ "pageSize", "100" },
|
||||
{ "continuation", "ENCODED" },
|
||||
{ "fields", "tenantId,domainName" },
|
||||
});
|
||||
|
||||
Assert.Null(parsed.Error);
|
||||
Assert.Equal(100, parsed.PageSize);
|
||||
Assert.Equal("ENCODED", parsed.Continuation);
|
||||
Assert.Equal("tenantId,domainName", parsed.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQuery_leaves_fields_null_when_absent()
|
||||
{
|
||||
var parsed = TenantConfigPagination.ParseQuery(new NameValueCollection { { "pageSize", "100" } });
|
||||
Assert.Null(parsed.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -121,10 +130,21 @@ public class TenantConfigPaginationTests
|
||||
[Fact]
|
||||
public void BuildNextLink_targets_config_all_with_pageSize_and_escaped_continuation()
|
||||
{
|
||||
var link = TenantConfigPagination.BuildNextLink(100, "TOKEN+/=");
|
||||
var link = TenantConfigPagination.BuildNextLink(100, "TOKEN+/=", fields: null);
|
||||
|
||||
Assert.StartsWith("/api/config/all?", link);
|
||||
Assert.Contains("pageSize=100", link);
|
||||
Assert.Contains("continuation=TOKEN%2B%2F%3D", link);
|
||||
Assert.DoesNotContain("fields=", link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNextLink_echoes_fields_so_the_projection_round_trips()
|
||||
{
|
||||
var link = TenantConfigPagination.BuildNextLink(100, "abc", fields: "tenantId,domainName");
|
||||
|
||||
Assert.Contains("pageSize=100", link);
|
||||
Assert.Contains("continuation=abc", link);
|
||||
Assert.Contains("fields=tenantId%2CdomainName", link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side keep-list projection for the paginated config/all surface. This is
|
||||
/// the backend security boundary that keeps tenant secrets off the wire even when a
|
||||
/// caller supplies fields= — requested keys are intersected with the keep-list, never
|
||||
/// widened. tenantId is always present.
|
||||
/// </summary>
|
||||
public class TenantConfigProjectionTests
|
||||
{
|
||||
private static TenantConfiguration FullConfig() => new()
|
||||
{
|
||||
TenantId = "contoso-tenant-id",
|
||||
DomainName = "contoso.example.com",
|
||||
PlanTier = "pro",
|
||||
Disabled = true,
|
||||
DisabledReason = "suspended",
|
||||
OnboardedBy = "alice@contoso.example.com",
|
||||
DataRetentionDays = 180,
|
||||
// secret-bearing fields that must never be projected:
|
||||
TeamsWebhookUrl = "https://contoso.webhook.office.com/secret",
|
||||
DiagnosticsBlobSasUrl = "https://blob.core.windows.net/c?sig=SECRETSAS",
|
||||
};
|
||||
|
||||
private static readonly string[] AllSafeKeys =
|
||||
{
|
||||
"tenantId", "domainName", "planTier", "disabled", "disabledReason",
|
||||
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Project_with_null_fields_emits_every_safe_field_and_no_secret()
|
||||
{
|
||||
var p = TenantConfigProjection.Project(FullConfig(), requested: null);
|
||||
|
||||
foreach (var key in AllSafeKeys) Assert.True(p.ContainsKey(key), $"missing {key}");
|
||||
Assert.Equal(AllSafeKeys.Length, p.Count);
|
||||
|
||||
// Belt-and-suspenders: no projected value carries a secret marker.
|
||||
var serialized = System.Text.Json.JsonSerializer.Serialize(p);
|
||||
Assert.DoesNotContain("sig=", serialized);
|
||||
Assert.DoesNotContain("webhook", serialized);
|
||||
Assert.DoesNotContain("SECRET", serialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_honours_a_requested_subset()
|
||||
{
|
||||
var requested = TenantConfigProjection.ParseFields("domainName,planTier");
|
||||
var p = TenantConfigProjection.Project(FullConfig(), requested);
|
||||
|
||||
Assert.Equal(new[] { "domainName", "planTier", "tenantId" }, p.Keys.OrderBy(k => k));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_always_includes_tenantId_even_when_not_requested()
|
||||
{
|
||||
var requested = TenantConfigProjection.ParseFields("domainName");
|
||||
var p = TenantConfigProjection.Project(FullConfig(), requested);
|
||||
|
||||
Assert.True(p.ContainsKey("tenantId"));
|
||||
Assert.Equal("contoso-tenant-id", p["tenantId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_silently_drops_unknown_or_secret_field_requests()
|
||||
{
|
||||
// A caller cannot widen the projection to a secret (or any non-safe key).
|
||||
var requested = TenantConfigProjection.ParseFields("tenantId,teamsWebhookUrl,bogus");
|
||||
var p = TenantConfigProjection.Project(FullConfig(), requested);
|
||||
|
||||
Assert.Equal(new[] { "tenantId" }, p.Keys);
|
||||
Assert.DoesNotContain("teamsWebhookUrl", p.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFields_is_case_insensitive_and_tolerates_whitespace_and_blanks()
|
||||
{
|
||||
var requested = TenantConfigProjection.ParseFields(" TENANTID , , DomainName ");
|
||||
var p = TenantConfigProjection.Project(FullConfig(), requested);
|
||||
|
||||
Assert.Equal(new[] { "domainName", "tenantId" }, p.Keys.OrderBy(k => k));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(",, ,")]
|
||||
public void ParseFields_returns_null_for_empty_input(string? fields)
|
||||
{
|
||||
Assert.Null(TenantConfigProjection.ParseFields(fields));
|
||||
}
|
||||
}
|
||||
+4
-15
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
@@ -81,26 +80,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var page = await _configService.GetConfigurationsPageAsync(parsed.PageSize.Value, azureToken);
|
||||
|
||||
// Keep-list projection — only non-sensitive identity / lifecycle / plan fields.
|
||||
var tenants = page.Items.Select(c => new
|
||||
{
|
||||
tenantId = c.TenantId,
|
||||
domainName = c.DomainName,
|
||||
planTier = c.PlanTier,
|
||||
disabled = c.Disabled,
|
||||
disabledReason = c.DisabledReason,
|
||||
onboardedAt = c.OnboardedAt,
|
||||
onboardedBy = c.OnboardedBy,
|
||||
lastUpdated = c.LastUpdated,
|
||||
dataRetentionDays = c.DataRetentionDays,
|
||||
}).ToList();
|
||||
// Keep-list projection — only non-sensitive fields, optionally narrowed to the
|
||||
// caller's fields= subset. Secrets can never be selected (intersection only).
|
||||
var tenants = TenantConfigProjection.ProjectAll(page.Items, parsed.Fields);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(page.NextRawToken))
|
||||
{
|
||||
var fp = TenantConfigPagination.Fingerprint(callerTenantId);
|
||||
var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
|
||||
nextLink = TenantConfigPagination.BuildNextLink(parsed.PageSize.Value, wireToken);
|
||||
nextLink = TenantConfigPagination.BuildNextLink(parsed.PageSize.Value, wireToken, parsed.Fields);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side keep-list projection for the paginated <c>GET /api/config/all</c> surface.
|
||||
/// Only non-sensitive identity / lifecycle / plan fields are ever emitted — secrets
|
||||
/// (webhook / SAS / branding URLs, allow-lists) can never leave the backend even if a
|
||||
/// caller requests them via <c>fields=</c>, because the requested set is intersected with
|
||||
/// this keep-list (unknown keys are silently dropped, never widened). <c>tenantId</c> is
|
||||
/// always included: the endpoint's whole purpose is tenant-ID discovery.
|
||||
/// </summary>
|
||||
public static class TenantConfigProjection
|
||||
{
|
||||
// Ordered keep-list: JSON key (camelCase — the wire contract the web + MCP already
|
||||
// consume) -> value selector. This is the ONLY set of fields that can reach the wire.
|
||||
private static readonly (string Key, Func<TenantConfiguration, object?> Value)[] SafeFields =
|
||||
{
|
||||
("tenantId", c => c.TenantId),
|
||||
("domainName", c => c.DomainName),
|
||||
("planTier", c => c.PlanTier),
|
||||
("disabled", c => c.Disabled),
|
||||
("disabledReason", c => c.DisabledReason),
|
||||
("onboardedAt", c => c.OnboardedAt),
|
||||
("onboardedBy", c => c.OnboardedBy),
|
||||
("lastUpdated", c => c.LastUpdated),
|
||||
("dataRetentionDays", c => c.DataRetentionDays),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parse a comma-separated field list into a case-insensitive set, or null when no
|
||||
/// usable subset was requested (→ emit all safe fields). Keys outside the keep-list
|
||||
/// are kept in the set but simply never match a real field in <see cref="Project"/>.
|
||||
/// </summary>
|
||||
public static HashSet<string>? ParseFields(string? fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fields)) return null;
|
||||
var requested = fields
|
||||
.Split(',')
|
||||
.Select(f => f.Trim())
|
||||
.Where(f => f.Length > 0)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return requested.Count > 0 ? requested : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project one tenant to a { jsonKey -> value } map containing only the requested
|
||||
/// safe fields (or all safe fields when <paramref name="requested"/> is null).
|
||||
/// <c>tenantId</c> is always present regardless of the request.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Project(TenantConfiguration config, HashSet<string>? requested)
|
||||
{
|
||||
if (config == null) throw new ArgumentNullException(nameof(config));
|
||||
var result = new Dictionary<string, object?>(SafeFields.Length);
|
||||
foreach (var (key, value) in SafeFields)
|
||||
{
|
||||
if (requested == null || key == "tenantId" || requested.Contains(key))
|
||||
result[key] = value(config);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Convenience: parse + project a sequence in one call.</summary>
|
||||
public static List<Dictionary<string, object?>> ProjectAll(
|
||||
IEnumerable<TenantConfiguration> configs, string? fields)
|
||||
{
|
||||
var requested = ParseFields(fields);
|
||||
return configs.Select(c => Project(c, requested)).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
/// <summary>Null when the caller did not opt into pagination (legacy bare-array mode).</summary>
|
||||
public int? PageSize { get; init; }
|
||||
public string? Continuation { get; init; }
|
||||
/// <summary>
|
||||
/// Raw comma-separated field subset (e.g. "tenantId,domainName"), or null for all
|
||||
/// safe fields. A projection, not a filter — it does NOT enter the continuation
|
||||
/// fingerprint (changing it mid-stream keeps the cursor valid).
|
||||
/// </summary>
|
||||
public string? Fields { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
var fieldsRaw = query?["fields"];
|
||||
|
||||
int? pageSize = null;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
@@ -54,7 +61,12 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
? continuationRaw
|
||||
: null;
|
||||
|
||||
return new Parsed { PageSize = pageSize, Continuation = continuation };
|
||||
return new Parsed
|
||||
{
|
||||
PageSize = pageSize,
|
||||
Continuation = continuation,
|
||||
Fields = string.IsNullOrEmpty(fieldsRaw) ? null : fieldsRaw,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryAcceptContinuation(
|
||||
@@ -67,12 +79,16 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason);
|
||||
}
|
||||
|
||||
public static string BuildNextLink(int pageSize, string wireContinuation)
|
||||
public static string BuildNextLink(int pageSize, string wireContinuation, string? fields)
|
||||
{
|
||||
var sb = new StringBuilder("/api/config/all");
|
||||
sb.Append('?');
|
||||
sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation));
|
||||
// Echo the projection so the caller can follow nextLink verbatim and keep the
|
||||
// same lean column set across every page (fields is not in the token fingerprint).
|
||||
if (!string.IsNullOrEmpty(fields))
|
||||
sb.Append("&fields=").Append(System.Uri.EscapeDataString(fields!));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { followNextLink } from '../client.js';
|
||||
import { SessionIdSchema } from '../tools/shared.js';
|
||||
import { ApiError } from '../client.js';
|
||||
import { toolError } from '../tools/error-handler.js';
|
||||
import { extractTenantList, TENANT_SAFE_FIELDS, projectTenantFields } from '../tools/admin.js';
|
||||
import { extractTenantList, TENANT_SAFE_FIELDS } from '../tools/admin.js';
|
||||
|
||||
// Importing the OAuth helper requires the env var that gates module load.
|
||||
// Set a dummy value before the import resolves so the throw doesn't fire.
|
||||
@@ -481,29 +481,3 @@ describe('list_tenants — extractTenantList keep-list projection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_tenants — projectTenantFields client-side field trim', () => {
|
||||
const tenants: Record<string, unknown>[] = [
|
||||
{ tenantId: 'aaa', domainName: 'alpha.example.com', planTier: 'pro', disabled: true },
|
||||
{ tenantId: 'bbb', domainName: 'beta.onmicrosoft.com', planTier: 'free', disabled: false },
|
||||
];
|
||||
|
||||
it('returns rows unchanged when no fields are given', () => {
|
||||
expect(projectTenantFields(tenants, undefined)).toEqual(tenants);
|
||||
expect(projectTenantFields(tenants, '')).toEqual(tenants);
|
||||
});
|
||||
|
||||
it('keeps only the requested fields', () => {
|
||||
const out = projectTenantFields(tenants, 'domainName');
|
||||
for (const t of out) expect(Object.keys(t).sort()).toEqual(['domainName', 'tenantId']);
|
||||
});
|
||||
|
||||
it('always retains tenantId even when not requested', () => {
|
||||
const out = projectTenantFields(tenants, 'planTier');
|
||||
expect(Object.keys(out[0]).sort()).toEqual(['planTier', 'tenantId']);
|
||||
});
|
||||
|
||||
it('tolerates whitespace and empty entries in the fields list', () => {
|
||||
const out = projectTenantFields(tenants, ' domainName , , planTier ');
|
||||
expect(Object.keys(out[0]).sort()).toEqual(['domainName', 'planTier', 'tenantId']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,27 +40,6 @@ export function extractTenantList(data: unknown): Record<string, unknown>[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only a comma-separated subset of fields from already-safe tenant rows.
|
||||
* `tenantId` is always retained — the tool's whole purpose is discovering tenant
|
||||
* IDs for other tools. Pagination-independent (operates on whatever page came
|
||||
* back), so it never interacts with the backend cursor.
|
||||
*/
|
||||
export function projectTenantFields(
|
||||
rows: Record<string, unknown>[],
|
||||
fields: string | undefined,
|
||||
): Record<string, unknown>[] {
|
||||
if (!fields) return rows;
|
||||
const keep = new Set(
|
||||
fields.split(',').map((f) => f.trim()).filter(Boolean).concat(['tenantId']),
|
||||
);
|
||||
return rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(row)) if (keep.has(key)) out[key] = row[key];
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAdminTools(server: McpServer): void {
|
||||
// Tool 11: get_api_usage
|
||||
server.tool(
|
||||
@@ -274,17 +253,18 @@ export function registerAdminTools(server: McpServer): void {
|
||||
'Global Admin only. Use this to discover tenant IDs for the tenantId parameter of other tools when running ' +
|
||||
'cross-tenant investigations. Returns only non-sensitive fields — secrets (webhook URLs, SAS URLs) are stripped ' +
|
||||
'server-side. Tenants are sorted by tenantId and returned in pages (default 100). For lean ID discovery pass ' +
|
||||
'`fields=tenantId,domainName`. ' +
|
||||
'`fields=tenantId,domainName` — the projection is applied server-side and is echoed in nextLink, so it carries ' +
|
||||
'across every page automatically. ' +
|
||||
'Pagination: when "nextLink" is present, more tenants are available — call again and pass that whole string ' +
|
||||
'back as "continuation". Stop when nextLink is absent.',
|
||||
{
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated subset of safe fields to return (e.g. "tenantId,domainName" for lean ID discovery). ' +
|
||||
'tenantId is always included. Default: all safe fields. Trims the returned page client-side.'),
|
||||
'tenantId is always included. Default: all safe fields. Applied server-side; unknown/secret keys are ignored.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(100)
|
||||
.describe('Page size (1-1000, default 100). Tenants are sorted by tenantId; follow nextLink to fetch more.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (pageSize, fields) round-trip correctly.'),
|
||||
},
|
||||
READ_ONLY,
|
||||
async (args) => withToolTelemetry('list_tenants', async () => {
|
||||
@@ -292,11 +272,12 @@ export function registerAdminTools(server: McpServer): void {
|
||||
const { fields, pageSize, continuation } = args;
|
||||
// /api/config/all supports opt-in pagination: passing pageSize switches the
|
||||
// backend from its legacy unpaginated bare array to a secret-stripped
|
||||
// { count, tenants, nextLink } envelope. extractTenantList re-applies the
|
||||
// keep-list as defense-in-depth; projectTenantFields honours an optional fields= trim.
|
||||
const path = followNextLink('/api/config/all', { pageSize }, continuation);
|
||||
// { count, tenants, nextLink } envelope, with fields= projected server-side
|
||||
// and echoed in nextLink. extractTenantList re-applies the keep-list as
|
||||
// defense-in-depth (harmless pass-through once the backend has projected).
|
||||
const path = followNextLink('/api/config/all', { pageSize, fields }, continuation);
|
||||
const data = await apiFetch(path) as { count?: number; tenants?: unknown[]; nextLink?: string | null };
|
||||
const tenants = projectTenantFields(extractTenantList({ tenants: data?.tenants ?? [] }), fields);
|
||||
const tenants = extractTenantList({ tenants: data?.tenants ?? [] });
|
||||
return toolResultText(
|
||||
{ count: tenants.length, tenants, nextLink: data?.nextLink ?? null },
|
||||
MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
|
||||
Reference in New Issue
Block a user