mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:
WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).
UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.
ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.
Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.
Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.
Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.
Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.
Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
51737174b2
commit
eaef1601d0
@@ -68,6 +68,18 @@ sites the 2026-08-13 audit froze). Three layers enforce it:
|
||||
- **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal
|
||||
vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case
|
||||
per key that WhenWritingNull omits.
|
||||
- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over
|
||||
the reachable wire-type graph fails on any `object` / collection-of-`object`
|
||||
property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`,
|
||||
heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt.
|
||||
The former deliberately-untyped slots (RuleDryRun trace, HealthCheck,
|
||||
GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports,
|
||||
MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw
|
||||
table entities left the wire entirely in the same pass: global-admins,
|
||||
tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` /
|
||||
`TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT
|
||||
partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON
|
||||
shape tests instead of parity facts.
|
||||
|
||||
The DTO rules: declaration order == wire order; a property is nullable exactly when a
|
||||
site can emit null (the key then vanishes — never add non-null defaults that would
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-31 (2)
|
||||
|
||||
* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests.
|
||||
|
||||
## 2026-08-31 (1)
|
||||
|
||||
* **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `<summary>` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job.
|
||||
|
||||
@@ -305,9 +305,9 @@ public class AdminWireParityTests
|
||||
// ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ----------------
|
||||
|
||||
[Fact]
|
||||
public void MaintenanceJobRunResponse_matches_the_backfill_shape()
|
||||
public void BackfillJobRunResponse_matches_the_backfill_shape()
|
||||
{
|
||||
var result = new OccurredUtcBackfillService.BackfillResult
|
||||
var result = new BackfillResult
|
||||
{
|
||||
Table = "audit",
|
||||
DryRun = true,
|
||||
@@ -330,7 +330,7 @@ public class AdminWireParityTests
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new MaintenanceJobRunResponse
|
||||
new BackfillJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
@@ -340,9 +340,9 @@ public class AdminWireParityTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaintenanceJobRunResponse_matches_the_reclassify_shape()
|
||||
public void ReclassifyJobRunResponse_matches_the_reclassify_shape()
|
||||
{
|
||||
var result = new LegacyReclassificationService.ReclassificationResult
|
||||
var result = new ReclassificationResult
|
||||
{
|
||||
Mode = "legacy_timeouts",
|
||||
DryRun = false,
|
||||
@@ -368,7 +368,7 @@ public class AdminWireParityTests
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new MaintenanceJobRunResponse
|
||||
new ReclassifyJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
@@ -768,14 +768,15 @@ public class AdminWireParityTests
|
||||
}
|
||||
|
||||
// ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not
|
||||
// the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminCreatedResponse_matches_the_created_member_shape()
|
||||
public void TenantAdminCreatedResponse_pins_the_created_member_shape()
|
||||
{
|
||||
var newAdmin = new TenantAdminEntity
|
||||
var newAdmin = new TenantAdminRow
|
||||
{
|
||||
PartitionKey = SampleTenantId,
|
||||
RowKey = "operator@contoso.com",
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "operator@contoso.com",
|
||||
IsEnabled = true,
|
||||
@@ -784,9 +785,46 @@ public class AdminWireParityTests
|
||||
Role = "Operator",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { admin = newAdmin },
|
||||
new TenantAdminCreatedResponse { Admin = newAdmin });
|
||||
Assert.Equal(
|
||||
"{\"admin\":{"
|
||||
+ $"\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"operator@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:00:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"role\":\"Operator\","
|
||||
+ "\"canManageBootstrapTokens\":false}}",
|
||||
TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys()
|
||||
{
|
||||
// Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the
|
||||
// ITableEntity keys of the storage entity must never reappear on the wire.
|
||||
var row = new TenantAdminRow
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "legacy@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = null,
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(row);
|
||||
Assert.Equal(
|
||||
$"{{\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"legacy@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:05:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"canManageBootstrapTokens\":false}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
// ---- TenantGroupManagement -----------------------------------------------------------
|
||||
|
||||
@@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
@@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
|
||||
@@ -471,7 +471,8 @@ public class AuthFunctionTests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Converts anonymous object to dynamic for property access in tests.
|
||||
/// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names
|
||||
/// (camelCase), so the asserts read the same casing a client would.
|
||||
/// </summary>
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
@@ -479,7 +480,7 @@ public class AuthFunctionTests
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -224,23 +224,23 @@ public class ConfigWireParityTests
|
||||
IReadOnlyList<TenantConfigFieldSchema> schema = new List<TenantConfigFieldSchema>
|
||||
{
|
||||
new TenantConfigFieldSchema(
|
||||
Name: "contactEmail",
|
||||
Type: "string",
|
||||
Format: "email",
|
||||
Nullable: true,
|
||||
Writable: true,
|
||||
Reason: null,
|
||||
GaOnly: false,
|
||||
RevertProtected: false),
|
||||
name: "contactEmail",
|
||||
type: "string",
|
||||
format: "email",
|
||||
nullable: true,
|
||||
writable: true,
|
||||
reason: null,
|
||||
gaOnly: false,
|
||||
revertProtected: false),
|
||||
new TenantConfigFieldSchema(
|
||||
Name: "homedAppClientId",
|
||||
Type: "string",
|
||||
Format: null,
|
||||
Nullable: true,
|
||||
Writable: false,
|
||||
Reason: "system-owned",
|
||||
GaOnly: true,
|
||||
RevertProtected: true),
|
||||
name: "homedAppClientId",
|
||||
type: "string",
|
||||
format: null,
|
||||
nullable: true,
|
||||
writable: false,
|
||||
reason: "system-owned",
|
||||
gaOnly: true,
|
||||
revertProtected: true),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
|
||||
@@ -16,6 +16,107 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class InfrastructureWireParityTests
|
||||
{
|
||||
// ---- AuthFunction: auth/me success body ----------------------------------------------
|
||||
// Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse.
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_matches_the_success_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "admin@contoso.com";
|
||||
var displayName = "Admin User";
|
||||
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = false,
|
||||
isTenantAdmin = true,
|
||||
isDelegated = true,
|
||||
delegatedTenantIds = managedTenantIds,
|
||||
role = "Admin",
|
||||
canManageBootstrapTokens = true,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = false,
|
||||
IsTenantAdmin = true,
|
||||
IsDelegated = true,
|
||||
DelegatedTenantIds = managedTenantIds,
|
||||
Role = "Admin",
|
||||
CanManageBootstrapTokens = true,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_omits_a_null_role()
|
||||
{
|
||||
// A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal,
|
||||
// so the key vanished — the DTO must vanish it identically.
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "reader@contoso.com";
|
||||
var displayName = "Reader User";
|
||||
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
|
||||
string? role = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = true,
|
||||
isTenantAdmin = false,
|
||||
isDelegated = false,
|
||||
delegatedTenantIds = Array.Empty<string>(),
|
||||
role,
|
||||
canManageBootstrapTokens = false,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = true,
|
||||
IsTenantAdmin = false,
|
||||
IsDelegated = false,
|
||||
DelegatedTenantIds = Array.Empty<string>(),
|
||||
Role = null,
|
||||
CanManageBootstrapTokens = false,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: IsGlobalAdmin -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -40,26 +141,111 @@ public class InfrastructureWireParityTests
|
||||
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null });
|
||||
}
|
||||
|
||||
// ---- AuthFunction: GetGlobalAdmins ---------------------------------------------------
|
||||
// ---- McpUserFunction: CheckMcpAccess (auth/mcp) --------------------------------------
|
||||
// Left side: the exact Dictionary<string, object?> the site built before the DTO —
|
||||
// insertion order == wire order, conditional keys only present when the tier applies.
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalAdminsResponse_matches_the_entity_listing_shape()
|
||||
public void CheckMcpAccessResponse_matches_the_plain_user_shape()
|
||||
{
|
||||
var admins = new List<GlobalAdminEntity>
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
new GlobalAdminEntity
|
||||
["allowed"] = true,
|
||||
["upn"] = "analyst@contoso.com",
|
||||
["accessGrant"] = "whitelist",
|
||||
["reason"] = "whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "analyst@contoso.com",
|
||||
AccessGrant = "whitelist",
|
||||
Reason = "whitelisted",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape()
|
||||
{
|
||||
var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" };
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = true,
|
||||
["upn"] = "admin@contoso.com",
|
||||
["accessGrant"] = "global-admin",
|
||||
["reason"] = "platform role",
|
||||
["isGlobalAdmin"] = true,
|
||||
["globalRole"] = "GlobalAdmin",
|
||||
["delegatedTenantIds"] = delegatedTenantIds,
|
||||
["delegatedRole"] = "DelegatedAdmin",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "admin@contoso.com",
|
||||
AccessGrant = "global-admin",
|
||||
Reason = "platform role",
|
||||
IsGlobalAdmin = true,
|
||||
GlobalRole = "GlobalAdmin",
|
||||
DelegatedTenantIds = delegatedTenantIds,
|
||||
DelegatedRole = "DelegatedAdmin",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_denied_shape()
|
||||
{
|
||||
// 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never
|
||||
// appear as false — the DTO's null slot omits it exactly like the old dictionary did.
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = false,
|
||||
["upn"] = "user@contoso.com",
|
||||
["accessGrant"] = "",
|
||||
["reason"] = "not whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = false,
|
||||
Upn = "user@contoso.com",
|
||||
AccessGrant = "",
|
||||
Reason = "not whitelisted",
|
||||
IsGlobalAdmin = null,
|
||||
GlobalRole = null,
|
||||
DelegatedTenantIds = null,
|
||||
DelegatedRole = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ----------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the
|
||||
// raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalAdminsResponse_pins_the_row_listing_shape()
|
||||
{
|
||||
var admins = new List<GlobalAdminRow>
|
||||
{
|
||||
new GlobalAdminRow
|
||||
{
|
||||
RowKey = "admin@contoso.com",
|
||||
Timestamp = new DateTimeOffset(2026, 8, 30, 10, 0, 0, TimeSpan.Zero),
|
||||
Upn = "admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "root@fabrikam.com",
|
||||
Role = "GlobalAdmin",
|
||||
},
|
||||
new GlobalAdminEntity
|
||||
new GlobalAdminRow
|
||||
{
|
||||
RowKey = "reader@fabrikam.com",
|
||||
Upn = "reader@fabrikam.com",
|
||||
IsEnabled = false,
|
||||
AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -68,19 +254,24 @@ public class InfrastructureWireParityTests
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { admins },
|
||||
new GetGlobalAdminsResponse { Admins = admins });
|
||||
var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins });
|
||||
Assert.Equal(
|
||||
"{\"admins\":["
|
||||
+ "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"},"
|
||||
+ "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
// ---- AuthFunction: AddGlobalAdmin ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddGlobalAdminResponse_matches_the_created_entity_shape()
|
||||
public void AddGlobalAdminResponse_pins_the_created_row_shape()
|
||||
{
|
||||
var newAdmin = new GlobalAdminEntity
|
||||
var newAdmin = new GlobalAdminRow
|
||||
{
|
||||
RowKey = "new.admin@contoso.com",
|
||||
Upn = "new.admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
@@ -88,9 +279,9 @@ public class InfrastructureWireParityTests
|
||||
Role = "GlobalAdmin",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { admin = newAdmin },
|
||||
new AddGlobalAdminResponse { Admin = newAdmin });
|
||||
Assert.Equal(
|
||||
"{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}",
|
||||
TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: HealthCheck ------------------------------------------------
|
||||
|
||||
@@ -560,8 +560,9 @@ public class MetricsWireParityTests
|
||||
public void MetricsSummaryResponse_matches_the_tally_shape()
|
||||
{
|
||||
var days = 30;
|
||||
// GetMetricsSummaryAsync returns List<object> of repository-built per-tenant rows
|
||||
// (shape mirrored from TableStorageService.GetMetricsSummaryAsync).
|
||||
// Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the
|
||||
// MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical
|
||||
// (incl. the windowDays duplicate on every item).
|
||||
var summary = new List<object>
|
||||
{
|
||||
new
|
||||
@@ -586,7 +587,24 @@ public class MetricsWireParityTests
|
||||
new MetricsSummaryResponse
|
||||
{
|
||||
Success = true,
|
||||
Summary = summary,
|
||||
Summary = new List<MetricsSummaryTenantItem>
|
||||
{
|
||||
new MetricsSummaryTenantItem
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
TotalSessions = 10,
|
||||
Succeeded = 6,
|
||||
Failed = 2,
|
||||
InProgress = 1,
|
||||
Pending = 0,
|
||||
Stalled = 0,
|
||||
AwaitingUser = 0,
|
||||
Incomplete = 1,
|
||||
Other = 0,
|
||||
FailureRate = 25.0,
|
||||
WindowDays = days,
|
||||
},
|
||||
},
|
||||
WindowDays = days,
|
||||
});
|
||||
}
|
||||
|
||||
+4
-3
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>
|
||||
{
|
||||
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
|
||||
});
|
||||
@@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
var members = new List<TenantAdminEntity>();
|
||||
var members = new List<TenantAdminRow>();
|
||||
if (enabledMember)
|
||||
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
|
||||
return admins;
|
||||
}
|
||||
|
||||
@@ -302,24 +302,29 @@ public class RulesWireParityTests
|
||||
}
|
||||
|
||||
// ---- GetPreviewWhitelist -------------------------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only
|
||||
// in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row.
|
||||
|
||||
[Fact]
|
||||
public void GetPreviewWhitelistResponse_matches_the_approved_tenant_listing_shape()
|
||||
public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape()
|
||||
{
|
||||
var approved = new List<PreviewWhitelistEntity>
|
||||
var approved = new List<PreviewWhitelistTenantEntry>
|
||||
{
|
||||
new PreviewWhitelistEntity
|
||||
{
|
||||
PartitionKey = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
RowKey = "approved",
|
||||
ApprovedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc),
|
||||
ApprovedBy = "admin@contoso.com",
|
||||
},
|
||||
new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" },
|
||||
new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenants = approved },
|
||||
new GetPreviewWhitelistResponse { Tenants = approved });
|
||||
var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved });
|
||||
Assert.Equal(
|
||||
"{\"tenants\":["
|
||||
+ "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"},"
|
||||
+ "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("approvedAt", json);
|
||||
}
|
||||
|
||||
// ---- GetPreviewNotificationEmail -----------------------------------------------------
|
||||
|
||||
@@ -176,6 +176,101 @@ public class TypedResponseGuardTests
|
||||
+ string.Join("\n ", nonFlat));
|
||||
}
|
||||
|
||||
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
|
||||
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
|
||||
// wire type carrying an `object` (or collection-of-object) property hides that slot's
|
||||
// shape from the manifest exactly like an anonymous body would. `[ProjectedItems]` slots
|
||||
// (fields=-projections) and dictionary VALUES (heterogeneous bags by design, e.g.
|
||||
// HealthCheck.Details) are exempt.
|
||||
|
||||
/// <summary>
|
||||
/// The deliberately-open object slots. Every other object-typed property on a reachable
|
||||
/// wire type is a regression: give the slot a concrete Shared type.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ObjectSlotBaseline = new(StringComparer.Ordinal)
|
||||
{
|
||||
// Matched: heterogeneous evidence dictionary; not matched: the evaluator's reason string.
|
||||
"AutopilotMonitor.Shared.Models.RuleDryRunCondition.Evidence",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void No_reachable_wire_type_property_is_object_typed_outside_the_baseline()
|
||||
{
|
||||
var shared = typeof(IApiResponse).Assembly;
|
||||
var roots = shared.GetTypes().Where(t =>
|
||||
!t.IsInterface && !t.IsAbstract &&
|
||||
(typeof(IApiResponse).IsAssignableFrom(t) ||
|
||||
t.GetCustomAttributes(typeof(WireContractAttribute), inherit: false).Length > 0));
|
||||
|
||||
var seen = new HashSet<Type>();
|
||||
var queue = new Queue<Type>(roots);
|
||||
var offenders = new List<string>();
|
||||
var baselineHits = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var type = queue.Dequeue();
|
||||
if (!seen.Add(type))
|
||||
continue;
|
||||
|
||||
foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
|
||||
{
|
||||
if (prop.GetCustomAttributes(typeof(ProjectedItemsAttribute), inherit: false).Length > 0)
|
||||
continue; // fields=-projection slot: IReadOnlyList<object> by design.
|
||||
|
||||
var slotType = prop.PropertyType;
|
||||
var elementType = EnumerableElementType(slotType);
|
||||
var effective = elementType ?? slotType;
|
||||
|
||||
if (effective == typeof(object))
|
||||
{
|
||||
var slot = $"{type.FullName}.{prop.Name}";
|
||||
if (ObjectSlotBaseline.Contains(slot))
|
||||
baselineHits.Add(slot);
|
||||
else
|
||||
offenders.Add(slot);
|
||||
}
|
||||
else if (effective.Assembly == shared && effective.IsClass && effective != typeof(string))
|
||||
{
|
||||
queue.Enqueue(effective);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"object-typed properties on reachable wire types (shape invisible to the manifest) — "
|
||||
+ "give the slot a concrete Shared type or document it in ObjectSlotBaseline:\n "
|
||||
+ string.Join("\n ", offenders.OrderBy(s => s, StringComparer.Ordinal)));
|
||||
|
||||
// Ratchet down: a baseline entry whose slot no longer exists (or is no longer object)
|
||||
// must be removed, so the documented-debt list stays truthful.
|
||||
var stale = ObjectSlotBaseline.Except(baselineHits).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"ObjectSlotBaseline entries that no longer match an object-typed slot — remove them:\n "
|
||||
+ string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Element type of a generic IEnumerable<T> slot (arrays included), EXCEPT dictionaries —
|
||||
/// dictionary values are heterogeneous bags by design and stay exempt. Null for scalars.
|
||||
/// </summary>
|
||||
private static Type? EnumerableElementType(Type type)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
return null;
|
||||
if (type.IsArray)
|
||||
return type.GetElementType();
|
||||
var enumerable = new[] { type }.Concat(type.GetInterfaces())
|
||||
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
|
||||
if (enumerable == null)
|
||||
return null;
|
||||
var element = enumerable.GetGenericArguments()[0];
|
||||
// KeyValuePair element ⇒ the slot is a dictionary; values are exempt by design.
|
||||
if (element.IsGenericType && element.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
|
||||
return null;
|
||||
return element;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
|
||||
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc)
|
||||
=> _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc);
|
||||
|
||||
public Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
public Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
=> _storage.GetMetricsSummaryAsync(tenantId, days);
|
||||
|
||||
public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var result = await _service.RunAsync(table, dryRun, maxRows, continuation);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new MaintenanceJobRunResponse
|
||||
await response.WriteAsJsonAsync(new BackfillJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new MaintenanceJobRunResponse
|
||||
await response.WriteAsJsonAsync(new ReclassifyJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
|
||||
@@ -275,7 +275,7 @@ public class AuthFunction
|
||||
return unresolved;
|
||||
}
|
||||
|
||||
GlobalAdminEntity newAdmin;
|
||||
GlobalAdminRow newAdmin;
|
||||
try
|
||||
{
|
||||
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
|
||||
@@ -588,30 +588,30 @@ public class AuthFunction
|
||||
string? role = needsAutoAdmin ? Constants.TenantRoles.Admin : memberRole?.Role;
|
||||
bool canManageBootstrapTokens = needsAutoAdmin || (memberRole?.CanManageBootstrapTokens ?? false);
|
||||
|
||||
return AuthDecisionResult.Success(new
|
||||
return AuthDecisionResult.Success(new AuthMeResponse
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin,
|
||||
isGlobalReader,
|
||||
isTenantAdmin,
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = isGlobalAdmin,
|
||||
IsGlobalReader = isGlobalReader,
|
||||
IsTenantAdmin = isTenantAdmin,
|
||||
// Delegated ("MSP") scope: the OTHER tenants this caller may manage (read-only this phase) and a
|
||||
// convenience flag. The web app uses these for fleet/switcher UI, bounded to this set.
|
||||
isDelegated,
|
||||
delegatedTenantIds = managedTenantIds,
|
||||
role,
|
||||
canManageBootstrapTokens,
|
||||
hasMcpAccess = mcpCheck.IsAllowed,
|
||||
IsDelegated = isDelegated,
|
||||
DelegatedTenantIds = managedTenantIds,
|
||||
Role = role,
|
||||
CanManageBootstrapTokens = canManageBootstrapTokens,
|
||||
HasMcpAccess = mcpCheck.IsAllowed,
|
||||
// Dual app-reg window: which app registration this tenant is homed on. The web app
|
||||
// stores this per browser (localStorage) so the NEXT login uses the right app —
|
||||
// the deferred, no-roundtrip learning mechanism of the parallel operation model.
|
||||
homedApp,
|
||||
HomedApp = homedApp,
|
||||
// EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is
|
||||
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
|
||||
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
|
||||
unrestrictedModeEnabled =
|
||||
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
|
||||
UnrestrictedModeEnabled =
|
||||
FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
|
||||
&& tenantConfig.UnrestrictedModeEnabled
|
||||
}, needsAutoAdmin);
|
||||
|
||||
+16
-31
@@ -203,38 +203,23 @@ public class McpUserFunction
|
||||
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId(), principal?.GetObjectId());
|
||||
|
||||
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
|
||||
var payload = new Dictionary<string, object?>
|
||||
// Only surface platform-role flags when the caller actually has one (null ⇒ key omitted).
|
||||
// A normal tenant user gets neither field: the MCP access-guard reads `globalRole` to decide
|
||||
// cross-tenant routing (and keeps reading `isGlobalAdmin === true` for back-compat /
|
||||
// write-tier hints), and we avoid hinting to ordinary callers that a platform tier even
|
||||
// exists. delegatedTenantIds bounds cross-tenant routing (/api/global/*?tenantId=<managed>)
|
||||
// to exactly these tenants and is only emitted for a caller holding a delegated assignment.
|
||||
await response.WriteAsJsonAsync(new CheckMcpAccessResponse
|
||||
{
|
||||
["allowed"] = result.IsAllowed,
|
||||
["upn"] = result.Upn,
|
||||
["accessGrant"] = result.AccessGrant,
|
||||
["reason"] = result.Reason,
|
||||
};
|
||||
// Only surface platform-role flags when the caller actually has one. A normal tenant user gets
|
||||
// neither field: the MCP access-guard reads `globalRole` to decide cross-tenant routing (and
|
||||
// keeps reading `isGlobalAdmin === true` for back-compat / write-tier hints), and we avoid
|
||||
// hinting to ordinary callers that a platform tier even exists.
|
||||
if (result.IsGlobalAdmin)
|
||||
{
|
||||
payload["isGlobalAdmin"] = true;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(result.GlobalRole))
|
||||
{
|
||||
payload["globalRole"] = result.GlobalRole; // "GlobalAdmin" | "GlobalReader"
|
||||
}
|
||||
// Delegated (scoped-global / MSP) scope, when present. The MCP access-guard reads
|
||||
// delegatedTenantIds to route the caller cross-tenant (/api/global/*?tenantId=<managed>) bounded to
|
||||
// exactly these tenants, and to reject any tool call that does not name one of them. Only emitted
|
||||
// for a caller that actually holds a delegated assignment — ordinary tenant users get neither field.
|
||||
if (result.DelegatedTenantIds is { Count: > 0 })
|
||||
{
|
||||
payload["delegatedTenantIds"] = result.DelegatedTenantIds; // string[] (lowercase)
|
||||
}
|
||||
if (!string.IsNullOrEmpty(result.DelegatedRole))
|
||||
{
|
||||
payload["delegatedRole"] = result.DelegatedRole; // "DelegatedAdmin" | "DelegatedReader"
|
||||
}
|
||||
await response.WriteAsJsonAsync(payload);
|
||||
Allowed = result.IsAllowed,
|
||||
Upn = result.Upn,
|
||||
AccessGrant = result.AccessGrant,
|
||||
Reason = result.Reason,
|
||||
IsGlobalAdmin = result.IsGlobalAdmin ? true : null,
|
||||
GlobalRole = string.IsNullOrEmpty(result.GlobalRole) ? null : result.GlobalRole,
|
||||
DelegatedTenantIds = result.DelegatedTenantIds is { Count: > 0 } ? result.DelegatedTenantIds : null,
|
||||
DelegatedRole = string.IsNullOrEmpty(result.DelegatedRole) ? null : result.DelegatedRole,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -109,7 +110,7 @@ public class GlobalAdminService
|
||||
/// <param name="homeTenantId">The Entra tenant the person signs in from (JWT tid).</param>
|
||||
/// <param name="objectId">The person's Entra object id, or null to pin it on their first sign-in.</param>
|
||||
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
|
||||
public async Task<GlobalAdminEntity> AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId)
|
||||
public async Task<GlobalAdminRow> AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
@@ -120,10 +121,8 @@ public class GlobalAdminService
|
||||
// Invalidate cache
|
||||
_cache.Remove($"global-role:{upn}");
|
||||
|
||||
return new GlobalAdminEntity
|
||||
return new GlobalAdminRow
|
||||
{
|
||||
PartitionKey = "GlobalAdmins",
|
||||
RowKey = upn,
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
AddedDate = DateTime.UtcNow,
|
||||
@@ -161,14 +160,12 @@ public class GlobalAdminService
|
||||
/// <summary>
|
||||
/// Gets all Global Admins
|
||||
/// </summary>
|
||||
public async Task<List<GlobalAdminEntity>> GetAllGlobalAdminsAsync()
|
||||
public async Task<List<GlobalAdminRow>> GetAllGlobalAdminsAsync()
|
||||
{
|
||||
var entries = await _adminRepo.GetAllGlobalAdminsAsync();
|
||||
|
||||
return entries.Select(e => new GlobalAdminEntity
|
||||
return entries.Select(e => new GlobalAdminRow
|
||||
{
|
||||
PartitionKey = "GlobalAdmins",
|
||||
RowKey = e.Upn,
|
||||
Upn = e.Upn,
|
||||
IsEnabled = e.IsEnabled,
|
||||
AddedDate = e.AddedAt,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,18 +26,8 @@ public class GlobalNotificationEntity : ITableEntity
|
||||
public bool Dismissed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO returned by the API for global notifications.
|
||||
/// </summary>
|
||||
public class GlobalNotificationDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "info";
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string? Href { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
// GlobalNotificationDto is wire contract and lives in AutopilotMonitor.Shared.Models
|
||||
// (NotificationsApiModels.cs).
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing persistent Global Admin in-app notifications.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AutopilotMonitor.Functions.Services.Monitoring;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -562,7 +563,9 @@ public class HealthCheckService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a health check operation
|
||||
/// Result of a health check operation. Not itself on the wire — HealthCheckFunction decomposes
|
||||
/// it into the typed response envelopes; the per-check item (HealthCheck) is wire contract and
|
||||
/// lives in AutopilotMonitor.Shared.Models (InfrastructureApiModels.cs).
|
||||
/// </summary>
|
||||
public class HealthCheckResult
|
||||
{
|
||||
@@ -570,15 +573,3 @@ public class HealthCheckResult
|
||||
public string OverallStatus { get; set; } = "unknown";
|
||||
public List<HealthCheck> Checks { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual health check result
|
||||
/// </summary>
|
||||
public class HealthCheck
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "unknown";
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public Dictionary<string, object>? Details { get; set; }
|
||||
}
|
||||
|
||||
@@ -50,32 +50,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public sealed class ReclassificationSample
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public string OldStatus { get; set; } = string.Empty;
|
||||
public string NewStatus { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ReclassificationResult
|
||||
{
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
public bool DryRun { get; set; }
|
||||
public int TenantsExamined { get; set; }
|
||||
public int SessionsExamined { get; set; }
|
||||
public int WouldChange { get; set; }
|
||||
public int Changed { get; set; }
|
||||
public int ToSucceeded { get; set; }
|
||||
public int ToIncomplete { get; set; }
|
||||
public int KeptFailed { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Errors { get; set; }
|
||||
/// <summary>True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue.</summary>
|
||||
public bool CapReached { get; set; }
|
||||
public List<ReclassificationSample> Samples { get; } = new();
|
||||
}
|
||||
// ReclassificationResult/ReclassificationSample are wire contract and live in
|
||||
// AutopilotMonitor.Shared.Models (AdminApiModels.cs).
|
||||
|
||||
public async Task<ReclassificationResult> ReclassifyLegacyTimeoutsAsync(
|
||||
string? tenantIdScope, bool dryRun, int maxSessions, string triggeredBy)
|
||||
|
||||
@@ -16,24 +16,7 @@ using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of a maintenance operation
|
||||
/// </summary>
|
||||
public class MaintenanceResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string TriggeredBy { get; set; } = string.Empty;
|
||||
public DateTime TriggeredAt { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
public bool StalledSessionsChecked { get; set; }
|
||||
public bool MetricsAggregated { get; set; }
|
||||
public string? AggregatedDate { get; set; }
|
||||
public bool DataCleanupExecuted { get; set; }
|
||||
public bool PlatformStatsRecomputed { get; set; }
|
||||
public int DevicesBlockedForExcessiveData { get; set; }
|
||||
public int ContactEmailsBackfilled { get; set; }
|
||||
}
|
||||
// MaintenanceResult is wire contract and lives in AutopilotMonitor.Shared.Models (AdminApiModels.cs).
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated service for maintenance tasks:
|
||||
|
||||
@@ -3,6 +3,7 @@ using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
@@ -34,26 +35,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public class BackfillResult
|
||||
{
|
||||
public string Table { get; set; } = string.Empty;
|
||||
public bool DryRun { get; set; }
|
||||
public int RowsExamined { get; set; }
|
||||
public int WouldWrite { get; set; }
|
||||
public int Written { get; set; }
|
||||
public int SkippedAlreadySet { get; set; }
|
||||
public int SkippedUndecodable { get; set; }
|
||||
public int Errors { get; set; }
|
||||
public string? NextContinuation { get; set; }
|
||||
public List<BackfillSample> Samples { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BackfillSample
|
||||
{
|
||||
public string PartitionKey { get; set; } = string.Empty;
|
||||
public string RowKey { get; set; } = string.Empty;
|
||||
public DateTime DecodedUtc { get; set; }
|
||||
}
|
||||
// BackfillResult/BackfillSample are wire contract and live in
|
||||
// AutopilotMonitor.Shared.Models (AdminApiModels.cs).
|
||||
|
||||
internal enum RowDecision { AlreadySet, Write, Undecodable }
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -124,18 +125,12 @@ public class PreviewWhitelistService
|
||||
|
||||
/// <summary>
|
||||
/// Returns all approved tenants (for Global Admin overview).
|
||||
/// Returns PreviewWhitelistEntity list for backward compatibility with existing API consumers.
|
||||
/// </summary>
|
||||
public async Task<List<PreviewWhitelistEntity>> GetAllApprovedAsync()
|
||||
public async Task<List<PreviewWhitelistTenantEntry>> GetAllApprovedAsync()
|
||||
{
|
||||
var tenantIds = await _configRepo.GetPreviewWhitelistAsync();
|
||||
|
||||
// Convert string list back to entity list for backward compatibility
|
||||
return tenantIds.Select(id => new PreviewWhitelistEntity
|
||||
{
|
||||
PartitionKey = id,
|
||||
RowKey = "approved"
|
||||
}).ToList();
|
||||
return tenantIds.Select(id => new PreviewWhitelistTenantEntry { TenantId = id }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -156,83 +156,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
|
||||
public static class RuleDryRunVerdict
|
||||
{
|
||||
public const string Fired = "fired";
|
||||
public const string SkippedByPrecondition = "skipped_by_precondition";
|
||||
public const string RequiredConditionNotMet = "required_condition_not_met";
|
||||
public const string NoConditionsMatched = "no_conditions_matched";
|
||||
public const string BelowConfidenceThreshold = "below_confidence_threshold";
|
||||
public const string NoEvents = "no_events";
|
||||
}
|
||||
|
||||
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
|
||||
public sealed class RuleDryRun
|
||||
{
|
||||
public string Verdict { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Number of events in the session the rule was evaluated against.</summary>
|
||||
public int EventCount { get; set; }
|
||||
|
||||
public List<RuleDryRunPrecondition> Preconditions { get; } = new();
|
||||
public List<RuleDryRunCondition> Conditions { get; } = new();
|
||||
|
||||
/// <summary>Empty unless all required conditions were met (mirrors the production path,
|
||||
/// which never reaches factor evaluation otherwise).</summary>
|
||||
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new();
|
||||
|
||||
public int BaseConfidence { get; set; }
|
||||
|
||||
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
|
||||
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
|
||||
public int? FinalConfidence { get; set; }
|
||||
|
||||
public int ConfidenceThreshold { get; set; }
|
||||
|
||||
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
|
||||
/// The dry-run itself never touches the session.</summary>
|
||||
public bool WouldMarkSessionAsFailed { get; set; }
|
||||
|
||||
/// <summary>The evidence map exactly as the production path would persist it on a
|
||||
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
|
||||
/// preview {{token}} interpolation of explanation/remediation.</summary>
|
||||
public Dictionary<string, object>? MatchedConditions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunPrecondition
|
||||
{
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string? DataField { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public bool Passed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunCondition
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? EventType { get; set; }
|
||||
public bool Required { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
|
||||
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
|
||||
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
|
||||
public object? Evidence { get; set; }
|
||||
|
||||
/// <summary>How many session events have this condition's eventType at all — the first
|
||||
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
|
||||
/// condition has no eventType.</summary>
|
||||
public int? MatchingEventCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunFactor
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
public int Weight { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
}
|
||||
// RuleDryRun, RuleDryRunVerdict and the per-stage trace classes are wire contract and live in
|
||||
// AutopilotMonitor.Shared.Models (RulesApiModels.cs).
|
||||
}
|
||||
|
||||
@@ -1457,7 +1457,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
|
||||
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
|
||||
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
public async Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
{
|
||||
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
|
||||
if (days < 1) days = 1;
|
||||
@@ -1489,22 +1489,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Incomplete (unknown, non-failure) is deliberately excluded from the denominator, and
|
||||
// non-terminal states (InProgress/AwaitingUser/Pending/Stalled) never belonged in it.
|
||||
var terminal = b.Succeeded + b.Failed;
|
||||
return (object)new
|
||||
return new MetricsSummaryTenantItem
|
||||
{
|
||||
tenantId = kvp.Key,
|
||||
totalSessions = b.Total,
|
||||
succeeded = b.Succeeded,
|
||||
failed = b.Failed,
|
||||
inProgress = b.InProgress,
|
||||
pending = b.Pending,
|
||||
stalled = b.Stalled,
|
||||
awaitingUser = b.AwaitingUser,
|
||||
incomplete = b.Incomplete,
|
||||
other = b.Other,
|
||||
failureRate = terminal > 0
|
||||
TenantId = kvp.Key,
|
||||
TotalSessions = b.Total,
|
||||
Succeeded = b.Succeeded,
|
||||
Failed = b.Failed,
|
||||
InProgress = b.InProgress,
|
||||
Pending = b.Pending,
|
||||
Stalled = b.Stalled,
|
||||
AwaitingUser = b.AwaitingUser,
|
||||
Incomplete = b.Incomplete,
|
||||
Other = b.Other,
|
||||
FailureRate = terminal > 0
|
||||
? Math.Round((double)b.Failed / terminal * 100, 1)
|
||||
: 0.0,
|
||||
windowDays = days
|
||||
WindowDays = days
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -83,7 +84,7 @@ public class TenantAdminsService
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="upn">User Principal Name</param>
|
||||
/// <param name="addedBy">UPN of the admin who is adding this user</param>
|
||||
public virtual async Task<TenantAdminEntity> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
public virtual async Task<TenantAdminRow> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
@@ -95,10 +96,8 @@ public class TenantAdminsService
|
||||
|
||||
_logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}");
|
||||
|
||||
return new TenantAdminEntity
|
||||
return new TenantAdminRow
|
||||
{
|
||||
PartitionKey = tenantId,
|
||||
RowKey = upn,
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
@@ -155,7 +154,7 @@ public class TenantAdminsService
|
||||
/// <summary>
|
||||
/// Gets all Tenant Admins for a specific tenant
|
||||
/// </summary>
|
||||
public virtual async Task<List<TenantAdminEntity>> GetTenantAdminsAsync(string tenantId)
|
||||
public virtual async Task<List<TenantAdminRow>> GetTenantAdminsAsync(string tenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
@@ -165,10 +164,8 @@ public class TenantAdminsService
|
||||
|
||||
_logger.LogInformation($"Retrieved {members.Count} Tenant Admins for tenant {tenantId}");
|
||||
|
||||
return members.Select(m => new TenantAdminEntity
|
||||
return members.Select(m => new TenantAdminRow
|
||||
{
|
||||
PartitionKey = m.TenantId,
|
||||
RowKey = m.Upn,
|
||||
TenantId = m.TenantId,
|
||||
Upn = m.Upn,
|
||||
IsEnabled = m.IsEnabled,
|
||||
@@ -255,7 +252,7 @@ public class TenantAdminsService
|
||||
/// <summary>
|
||||
/// Adds a tenant member with a specific role.
|
||||
/// </summary>
|
||||
public async Task<TenantAdminEntity> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
|
||||
public async Task<TenantAdminRow> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
@@ -267,10 +264,8 @@ public class TenantAdminsService
|
||||
|
||||
_logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy);
|
||||
|
||||
return new TenantAdminEntity
|
||||
return new TenantAdminRow
|
||||
{
|
||||
PartitionKey = tenantId,
|
||||
RowKey = upn,
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
|
||||
@@ -57,20 +57,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
=> new(false, failure, error, backupId, Array.Empty<string>(), null, drift);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the tenant-config field schema (see TenantConfigPatchService.BuildFieldsSchema).
|
||||
/// Serialized camelCase to the API. Nulls/false flags are serialized as-is — the payload is
|
||||
/// tiny and consumed by a model, which reads explicit values better than absent keys.
|
||||
/// </summary>
|
||||
public sealed record TenantConfigFieldSchema(
|
||||
string Name,
|
||||
string Type,
|
||||
string? Format,
|
||||
bool Nullable,
|
||||
bool Writable,
|
||||
string? Reason,
|
||||
bool GaOnly,
|
||||
bool RevertProtected);
|
||||
// TenantConfigFieldSchema is wire contract and lives in AutopilotMonitor.Shared.Models
|
||||
// (ConfigApiModels.cs); the schema builder (BuildFieldsSchema) stays here.
|
||||
|
||||
/// <summary>
|
||||
/// Transactional field-level writes against a tenant's configuration row:
|
||||
@@ -498,14 +486,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var writable = !BaseDeniedFields.Contains(prop.Name);
|
||||
fields.Add(new TenantConfigFieldSchema(
|
||||
Name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1),
|
||||
Type: type,
|
||||
Format: format,
|
||||
Nullable: nullable,
|
||||
Writable: writable,
|
||||
Reason: writable ? null : DeniedReason(prop.Name),
|
||||
GaOnly: writable && GaOnlyFields.Contains(prop.Name),
|
||||
RevertProtected: RevertProtectedFields.Contains(prop.Name)));
|
||||
name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1),
|
||||
type: type,
|
||||
format: format,
|
||||
nullable: nullable,
|
||||
writable: writable,
|
||||
reason: writable ? null : DeniedReason(prop.Name),
|
||||
gaOnly: writable && GaOnlyFields.Contains(prop.Name),
|
||||
revertProtected: RevertProtectedFields.Contains(prop.Name)));
|
||||
}
|
||||
return fields.OrderBy(f => f.Name, StringComparer.Ordinal).ToList();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
@@ -12,6 +12,7 @@ import crypto from 'node:crypto';
|
||||
import { extractTokenClaims, isTokenExpired } from './auth.js';
|
||||
import { runWithCaller } from './client.js';
|
||||
import { API_BASE_URL, getPublicBaseUrl, parsePositiveInt } from './config.js';
|
||||
import type { CheckMcpAccessResponse } from './generated/wire-types.generated.js';
|
||||
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
@@ -187,19 +188,9 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
|
||||
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false, infraError: true };
|
||||
}
|
||||
|
||||
const data = JSON.parse(text) as {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
accessGrant?: string;
|
||||
isGlobalAdmin?: boolean;
|
||||
// Platform role: "GlobalAdmin" | "GlobalReader" (absent → no platform role). The read-only
|
||||
// GlobalReader gets the same cross-tenant routing as GA because this server is read-only.
|
||||
globalRole?: string;
|
||||
// Delegated (scoped-global / MSP) scope: the managed tenant IDs (lowercase) and strongest role.
|
||||
// Present only for a caller that holds a delegated assignment.
|
||||
delegatedTenantIds?: unknown;
|
||||
delegatedRole?: string;
|
||||
};
|
||||
// Wire type is generated from the backend DTO (CheckMcpAccessResponse). The read-only
|
||||
// GlobalReader gets the same cross-tenant routing as GA because this server is read-only.
|
||||
const data = JSON.parse(text) as CheckMcpAccessResponse;
|
||||
// Normalize defensively: accept only a non-empty array of strings, lowercased. Anything else
|
||||
// (missing, wrong type, empty) collapses to undefined → the caller is treated as non-delegated.
|
||||
const delegatedTenantIds = Array.isArray(data.delegatedTenantIds)
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface ActiveUserItem {
|
||||
secondsAgo: number;
|
||||
}
|
||||
|
||||
/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */
|
||||
/** Response of POST auth/global-admins (201): the created row. */
|
||||
export interface AddGlobalAdminResponse {
|
||||
admin: unknown;
|
||||
admin: GlobalAdminRow;
|
||||
}
|
||||
|
||||
/** Response of POST vulnerability/ignored-software: how many entries were added. */
|
||||
@@ -540,6 +540,29 @@ export interface AuditLogListResponse {
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */
|
||||
export interface AuthMeResponse {
|
||||
tenantId: string;
|
||||
upn: string;
|
||||
displayName: string;
|
||||
objectId: string;
|
||||
isGlobalAdmin: boolean;
|
||||
isGlobalReader: boolean;
|
||||
isTenantAdmin: boolean;
|
||||
/** True when the caller holds delegated ("MSP") assignments to other tenants. */
|
||||
isDelegated: boolean;
|
||||
/** The OTHER tenants this caller may manage; empty for non-delegated callers. */
|
||||
delegatedTenantIds: string[];
|
||||
/** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */
|
||||
role?: string;
|
||||
canManageBootstrapTokens: boolean;
|
||||
hasMcpAccess: boolean;
|
||||
/** "primary" or "legacy" — which app registration this tenant is homed on. */
|
||||
homedApp: string;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */
|
||||
export interface AutoResolveCpeMappingResponse {
|
||||
resolved: AutoResolveResultItem[];
|
||||
@@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse {
|
||||
willAutoFlipHoming: boolean;
|
||||
}
|
||||
|
||||
/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */
|
||||
export interface BackfillJobRunResponse {
|
||||
success: boolean;
|
||||
result: BackfillResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */
|
||||
export interface BackfillResult {
|
||||
table: string;
|
||||
dryRun: boolean;
|
||||
rowsExamined: number;
|
||||
wouldWrite: number;
|
||||
written: number;
|
||||
skippedAlreadySet: number;
|
||||
skippedUndecodable: number;
|
||||
errors: number;
|
||||
nextContinuation?: string;
|
||||
samples: BackfillSample[];
|
||||
}
|
||||
|
||||
/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */
|
||||
export interface BackfillSample {
|
||||
partitionKey: string;
|
||||
rowKey: string;
|
||||
decodedUtc: string;
|
||||
}
|
||||
|
||||
/** Response of POST devices/block: block/kill acknowledgement. */
|
||||
export interface BlockDeviceResponse {
|
||||
success: boolean;
|
||||
@@ -648,6 +700,22 @@ export interface BlockingAppInterval {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */
|
||||
export interface CheckMcpAccessResponse {
|
||||
allowed: boolean;
|
||||
upn: string;
|
||||
accessGrant: string;
|
||||
reason: string;
|
||||
/** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */
|
||||
isGlobalAdmin?: boolean;
|
||||
/** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */
|
||||
globalRole?: string;
|
||||
/** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */
|
||||
delegatedTenantIds?: string[];
|
||||
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
|
||||
delegatedRole?: string;
|
||||
}
|
||||
|
||||
/** A factor that increases confidence when matched */
|
||||
export interface ConfidenceFactor {
|
||||
/** Descriptive name for this factor */
|
||||
@@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics {
|
||||
whiteGlovePercentage: number;
|
||||
}
|
||||
|
||||
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */
|
||||
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */
|
||||
export interface DetailedHealthCheckResponse {
|
||||
service: string;
|
||||
timestamp: string;
|
||||
overallStatus: string;
|
||||
checks: unknown[];
|
||||
checks: HealthCheck[];
|
||||
version: string;
|
||||
commitHash: string;
|
||||
buildUtc: string;
|
||||
@@ -1062,8 +1130,8 @@ export interface DistressReportListResponse {
|
||||
export interface DryRunAnalyzeRuleResponse {
|
||||
success: boolean;
|
||||
sessionId: string;
|
||||
/** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
|
||||
result: unknown;
|
||||
/** The full diagnostic trace of the evaluation. */
|
||||
result: RuleDryRun;
|
||||
}
|
||||
|
||||
export interface EfficiencyOffender {
|
||||
@@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse {
|
||||
attemptNumber?: number;
|
||||
}
|
||||
|
||||
/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */
|
||||
/** Response of GET auth/global-admins: every Global Admin/Reader row. */
|
||||
export interface GetGlobalAdminsResponse {
|
||||
admins: unknown[];
|
||||
admins: GlobalAdminRow[];
|
||||
}
|
||||
|
||||
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
|
||||
@@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse {
|
||||
|
||||
/** Response of GET preview/whitelist: every approved tenant (Global Admin only). */
|
||||
export interface GetPreviewWhitelistResponse {
|
||||
/** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
|
||||
tenants: unknown[];
|
||||
tenants: PreviewWhitelistTenantEntry[];
|
||||
}
|
||||
|
||||
/** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */
|
||||
@@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse {
|
||||
export interface GetTenantConfigFieldsSchemaResponse {
|
||||
count: number;
|
||||
writableCount: number;
|
||||
/** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */
|
||||
fields: unknown[];
|
||||
fields: TenantConfigFieldSchema[];
|
||||
}
|
||||
|
||||
/** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */
|
||||
@@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse {
|
||||
epssLastRun?: EpssRefreshRunWire;
|
||||
}
|
||||
|
||||
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
|
||||
export interface GlobalAdminRow {
|
||||
/** User Principal Name (lowercase). */
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
/** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Global average benchmarks for geographic comparison. */
|
||||
export interface GlobalAverages {
|
||||
avgDurationMinutes: number;
|
||||
@@ -1713,6 +1790,16 @@ export interface GlobalAverages {
|
||||
totalDoBytesFromHttp: number;
|
||||
}
|
||||
|
||||
/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */
|
||||
export interface GlobalNotificationDto {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
href?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */
|
||||
export interface GraphFeatureStatusItem {
|
||||
name: string;
|
||||
@@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse {
|
||||
dataQualityNotice: string;
|
||||
}
|
||||
|
||||
/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */
|
||||
export interface HealthCheck {
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Response of GET health: liveness probe plus the backend build identity. */
|
||||
export interface HealthCheckResponse {
|
||||
status: string;
|
||||
@@ -2100,19 +2196,26 @@ export interface LogPatternTestResult {
|
||||
readonly notes: string[];
|
||||
}
|
||||
|
||||
/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */
|
||||
export interface MaintenanceJobRunResponse {
|
||||
/** Run report of a manual maintenance run (POST maintenance/trigger). */
|
||||
export interface MaintenanceResult {
|
||||
success: boolean;
|
||||
/** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
result: unknown;
|
||||
error?: string;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
durationMs: number;
|
||||
stalledSessionsChecked: boolean;
|
||||
metricsAggregated: boolean;
|
||||
aggregatedDate?: string;
|
||||
dataCleanupExecuted: boolean;
|
||||
platformStatsRecomputed: boolean;
|
||||
devicesBlockedForExcessiveData: number;
|
||||
contactEmailsBackfilled: number;
|
||||
}
|
||||
|
||||
/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */
|
||||
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
|
||||
export interface McpHealthCheckResponse {
|
||||
timestamp: string;
|
||||
check: unknown;
|
||||
check: HealthCheck;
|
||||
}
|
||||
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
|
||||
@@ -2147,14 +2250,14 @@ export interface McpUserEntry {
|
||||
usagePlan?: string;
|
||||
}
|
||||
|
||||
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */
|
||||
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */
|
||||
export interface MetricsSummaryResponse {
|
||||
success: boolean;
|
||||
summary: Partial<MetricsSummaryTenantItem>[];
|
||||
summary: MetricsSummaryTenantItem[];
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */
|
||||
/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */
|
||||
export interface MetricsSummaryTenantItem {
|
||||
tenantId: string;
|
||||
totalSessions: number;
|
||||
@@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem {
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */
|
||||
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */
|
||||
export interface NotificationListResponse {
|
||||
success: boolean;
|
||||
notifications: unknown[];
|
||||
notifications: GlobalNotificationDto[];
|
||||
}
|
||||
|
||||
/** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */
|
||||
@@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics {
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */
|
||||
export interface PreviewWhitelistTenantEntry {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
|
||||
export interface ProgressGetSessionEventsResponse {
|
||||
success: boolean;
|
||||
@@ -2403,6 +2511,40 @@ export interface RebootSpan {
|
||||
segmentKey: string;
|
||||
}
|
||||
|
||||
/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */
|
||||
export interface ReclassificationResult {
|
||||
mode: string;
|
||||
dryRun: boolean;
|
||||
tenantsExamined: number;
|
||||
sessionsExamined: number;
|
||||
wouldChange: number;
|
||||
changed: number;
|
||||
toSucceeded: number;
|
||||
toIncomplete: number;
|
||||
keptFailed: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
/** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */
|
||||
capReached: boolean;
|
||||
readonly samples: ReclassificationSample[];
|
||||
}
|
||||
|
||||
export interface ReclassificationSample {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */
|
||||
export interface ReclassifyJobRunResponse {
|
||||
success: boolean;
|
||||
result: ReclassificationResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */
|
||||
export interface ReducerVerificationReport {
|
||||
tenantId: string;
|
||||
@@ -2530,6 +2672,53 @@ export interface RuleCondition {
|
||||
eventAFilterValue: string;
|
||||
}
|
||||
|
||||
/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */
|
||||
export interface RuleDryRun {
|
||||
verdict: string;
|
||||
/** Number of events in the session the rule was evaluated against. */
|
||||
eventCount: number;
|
||||
readonly preconditions: RuleDryRunPrecondition[];
|
||||
readonly conditions: RuleDryRunCondition[];
|
||||
/** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */
|
||||
readonly confidenceFactors: RuleDryRunFactor[];
|
||||
baseConfidence: number;
|
||||
/** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */
|
||||
finalConfidence?: number;
|
||||
confidenceThreshold: number;
|
||||
/** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */
|
||||
wouldMarkSessionAsFailed: boolean;
|
||||
/** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */
|
||||
matchedConditions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuleDryRunCondition {
|
||||
signal: string;
|
||||
source: string;
|
||||
eventType?: string;
|
||||
required: boolean;
|
||||
matched: boolean;
|
||||
/** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */
|
||||
evidence?: unknown;
|
||||
/** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */
|
||||
matchingEventCount?: number;
|
||||
}
|
||||
|
||||
export interface RuleDryRunFactor {
|
||||
signal: string;
|
||||
condition: string;
|
||||
weight: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface RuleDryRunPrecondition {
|
||||
source: string;
|
||||
eventType: string;
|
||||
dataField?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
|
||||
export interface RulePrecondition {
|
||||
/** Source of the fact. Currently only event_data. */
|
||||
@@ -3223,8 +3412,22 @@ export interface TemplateVariable {
|
||||
|
||||
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
|
||||
export interface TenantAdminCreatedResponse {
|
||||
/** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
admin: unknown;
|
||||
admin: TenantAdminRow;
|
||||
}
|
||||
|
||||
/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
|
||||
export interface TenantAdminRow {
|
||||
/** Tenant ID (lowercase). */
|
||||
tenantId: string;
|
||||
/** User Principal Name (lowercase). */
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
/** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */
|
||||
role?: string;
|
||||
/** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */
|
||||
canManageBootstrapTokens: boolean;
|
||||
}
|
||||
|
||||
/** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */
|
||||
@@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem {
|
||||
diff?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */
|
||||
export interface TenantConfigFieldSchema {
|
||||
name: string;
|
||||
type: string;
|
||||
format?: string;
|
||||
nullable: boolean;
|
||||
writable: boolean;
|
||||
reason?: string;
|
||||
gaOnly: boolean;
|
||||
revertProtected: boolean;
|
||||
}
|
||||
|
||||
/** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */
|
||||
export interface TenantConfigPatchOutcomeResponse {
|
||||
success: boolean;
|
||||
@@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse {
|
||||
export interface TriggerMaintenanceResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
result: unknown;
|
||||
result: MaintenanceResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GetGlobalMcpUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
GetMyMcpUsageResponse,
|
||||
GetTenantConfigFieldsSchemaResponse,
|
||||
GetUnmatchedSoftwareResponse,
|
||||
PlatformAgentMetricsResponse,
|
||||
QueryRawTableResponse,
|
||||
@@ -1322,7 +1323,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
async (args) => withToolTelemetry('get_tenant_config_schema', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/config/fields-schema');
|
||||
const data = await apiFetch('/api/config/fields-schema') as GetTenantConfigFieldsSchemaResponse;
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('get_tenant_config_schema', args, error);
|
||||
|
||||
@@ -127,11 +127,8 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
body: JSON.stringify({ sessionId: args.sessionId, rule: args.rule }),
|
||||
}) as DryRunAnalyzeRuleResponse;
|
||||
|
||||
// `result` is `unknown` on the wire by design (the RuleDryRun trace lives in the
|
||||
// Functions assembly) — read the two fields this tool needs via a local view.
|
||||
const result = data.result as { verdict?: string; matchedConditions?: Record<string, unknown> } | undefined;
|
||||
const rule = args.rule as Record<string, unknown>;
|
||||
const mc = result?.matchedConditions;
|
||||
const mc = data.result.matchedConditions;
|
||||
|
||||
// Preview the explanation/remediation exactly as the portal would render them
|
||||
// for this session's evidence ({{token}} interpolation).
|
||||
|
||||
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc);
|
||||
|
||||
// --- Metrics Summary (Agent API) ---
|
||||
Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
|
||||
Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
|
||||
|
||||
// --- Rule Stats ---
|
||||
Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
|
||||
|
||||
@@ -101,20 +101,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc,
|
||||
/// POST maintenance/reclassify-legacy): the service's run report plus trigger attribution.
|
||||
/// Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger
|
||||
/// attribution. Key layout is identical to <see cref="ReclassifyJobRunResponse"/> — the two
|
||||
/// one-shot job triggers share one wire shape, split into two classes only for the typed slot.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class MaintenanceJobRunResponse : IApiResponse
|
||||
public class BackfillJobRunResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public BackfillResult Result { get; set; } = default!;
|
||||
public string TriggeredBy { get; set; } = default!;
|
||||
public DateTime TriggeredAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service-specific run report (BackfillResult / ReclassificationResult — types owned by the
|
||||
/// Functions project, so this stays <c>object</c>; serialized by runtime type, wire-identical).
|
||||
/// </summary>
|
||||
public object Result { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Response of POST maintenance/reclassify-legacy: the reclassification run report plus
|
||||
/// trigger attribution. See <see cref="BackfillJobRunResponse"/> for the shared key layout.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class ReclassifyJobRunResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public ReclassificationResult Result { get; set; } = default!;
|
||||
public string TriggeredBy { get; set; } = default!;
|
||||
public DateTime TriggeredAt { get; set; }
|
||||
}
|
||||
@@ -125,17 +133,91 @@ namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The MaintenanceResult run report (type owned by the Functions project, so this stays
|
||||
/// <c>object</c>; serialized by runtime type, wire-identical).
|
||||
/// </summary>
|
||||
public object Result { get; set; } = default!;
|
||||
|
||||
public MaintenanceResult Result { get; set; } = default!;
|
||||
public string TriggeredBy { get; set; } = default!;
|
||||
public DateTime TriggeredAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class BackfillResult
|
||||
{
|
||||
public string Table { get; set; } = string.Empty;
|
||||
public bool DryRun { get; set; }
|
||||
public int RowsExamined { get; set; }
|
||||
public int WouldWrite { get; set; }
|
||||
public int Written { get; set; }
|
||||
public int SkippedAlreadySet { get; set; }
|
||||
public int SkippedUndecodable { get; set; }
|
||||
public int Errors { get; set; }
|
||||
public string? NextContinuation { get; set; }
|
||||
public List<BackfillSample> Samples { get; set; } = new List<BackfillSample>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify
|
||||
/// the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class BackfillSample
|
||||
{
|
||||
public string PartitionKey { get; set; } = string.Empty;
|
||||
public string RowKey { get; set; } = string.Empty;
|
||||
public DateTime DecodedUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public sealed class ReclassificationResult
|
||||
{
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
public bool DryRun { get; set; }
|
||||
public int TenantsExamined { get; set; }
|
||||
public int SessionsExamined { get; set; }
|
||||
public int WouldChange { get; set; }
|
||||
public int Changed { get; set; }
|
||||
public int ToSucceeded { get; set; }
|
||||
public int ToIncomplete { get; set; }
|
||||
public int KeptFailed { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Errors { get; set; }
|
||||
/// <summary>True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue.</summary>
|
||||
public bool CapReached { get; set; }
|
||||
public List<ReclassificationSample> Samples { get; } = new List<ReclassificationSample>();
|
||||
}
|
||||
|
||||
// Declaration order == wire order.
|
||||
public sealed class ReclassificationSample
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public string OldStatus { get; set; } = string.Empty;
|
||||
public string NewStatus { get; set; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Run report of a manual maintenance run (POST maintenance/trigger).</summary>
|
||||
// Declaration order == wire order.
|
||||
public class MaintenanceResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string TriggeredBy { get; set; } = string.Empty;
|
||||
public DateTime TriggeredAt { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
public bool StalledSessionsChecked { get; set; }
|
||||
public bool MetricsAggregated { get; set; }
|
||||
public string? AggregatedDate { get; set; }
|
||||
public bool DataCleanupExecuted { get; set; }
|
||||
public bool PlatformStatsRecomputed { get; set; }
|
||||
public int DevicesBlockedForExcessiveData { get; set; }
|
||||
public int ContactEmailsBackfilled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One customs-archive run (one (tenantId, historyRowKey) partition) with per-source-table
|
||||
/// row counts, as listed by GET global/customs-archive.
|
||||
@@ -358,11 +440,34 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// Declaration order == wire order.
|
||||
public class TenantAdminCreatedResponse : IApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The stored TenantAdminEntity row (type owned by the Functions project, so this stays
|
||||
/// <c>object</c>; serialized by runtime type, wire-identical).
|
||||
/// </summary>
|
||||
public object Admin { get; set; } = default!;
|
||||
public TenantAdminRow Admin { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array
|
||||
/// GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the
|
||||
/// storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the
|
||||
/// pre-2026-08-31 wire carried are storage internals and were dropped from the contract
|
||||
/// (no consumer read them).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class TenantAdminRow
|
||||
{
|
||||
/// <summary>Tenant ID (lowercase).</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>User Principal Name (lowercase).</summary>
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
public DateTime AddedDate { get; set; }
|
||||
public string AddedBy { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin.</summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>Whether this Operator can manage bootstrap tokens (only relevant for Operator).</summary>
|
||||
public bool CanManageBootstrapTokens { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response of GET global/tenant-groups: every group with tenants + assignees.</summary>
|
||||
|
||||
@@ -86,11 +86,45 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int Count { get; set; }
|
||||
public int WritableCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema entries (<c>TenantConfigFieldSchema</c> records, defined in the Functions
|
||||
/// project — typed as object here because Shared cannot reference it).
|
||||
/// </summary>
|
||||
public IReadOnlyList<object> Fields { get; set; } = default!;
|
||||
public IReadOnlyList<TenantConfigFieldSchema> Fields { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema).
|
||||
/// Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and
|
||||
/// set cases are pinned by ConfigWireParityTests.
|
||||
/// </summary>
|
||||
// Declaration order == wire order (matches the former positional-record constructor order).
|
||||
public sealed class TenantConfigFieldSchema
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string? Format { get; set; }
|
||||
public bool Nullable { get; set; }
|
||||
public bool Writable { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public bool GaOnly { get; set; }
|
||||
public bool RevertProtected { get; set; }
|
||||
|
||||
public TenantConfigFieldSchema(
|
||||
string name,
|
||||
string type,
|
||||
string? format,
|
||||
bool nullable,
|
||||
bool writable,
|
||||
string? reason,
|
||||
bool gaOnly,
|
||||
bool revertProtected)
|
||||
{
|
||||
Name = name;
|
||||
Type = type;
|
||||
Format = format;
|
||||
Nullable = nullable;
|
||||
Writable = writable;
|
||||
Reason = reason;
|
||||
GaOnly = gaOnly;
|
||||
RevertProtected = revertProtected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,41 @@ using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Success body of GET auth/me: the caller's resolved identity, roles and effective
|
||||
/// entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error
|
||||
/// shapes and stay anonymous by design.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class AuthMeResponse : IApiResponse
|
||||
{
|
||||
public string TenantId { get; set; } = default!;
|
||||
public string Upn { get; set; } = default!;
|
||||
public string DisplayName { get; set; } = default!;
|
||||
public string ObjectId { get; set; } = default!;
|
||||
public bool IsGlobalAdmin { get; set; }
|
||||
public bool IsGlobalReader { get; set; }
|
||||
public bool IsTenantAdmin { get; set; }
|
||||
|
||||
/// <summary>True when the caller holds delegated ("MSP") assignments to other tenants.</summary>
|
||||
public bool IsDelegated { get; set; }
|
||||
|
||||
/// <summary>The OTHER tenants this caller may manage; empty for non-delegated callers.</summary>
|
||||
public IReadOnlyCollection<string> DelegatedTenantIds { get; set; } = default!;
|
||||
|
||||
/// <summary>Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller.</summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
public bool CanManageBootstrapTokens { get; set; }
|
||||
public bool HasMcpAccess { get; set; }
|
||||
|
||||
/// <summary>"primary" or "legacy" — which app registration this tenant is homed on.</summary>
|
||||
public string HomedApp { get; set; } = default!;
|
||||
|
||||
public bool BootstrapTokenEnabled { get; set; }
|
||||
public bool UnrestrictedModeEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET auth/is-global-admin: whether the caller holds the Global Admin
|
||||
/// platform role, echoing the caller's UPN.
|
||||
@@ -18,24 +53,38 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET auth/global-admins: every Global Admin/Reader row. Items are
|
||||
/// <c>GlobalAdminEntity</c> table entities (backend-project type, serialized by runtime
|
||||
/// type — includes the ITableEntity keys, exactly as the anonymous site did).
|
||||
/// Response of GET auth/global-admins: every Global Admin/Reader row.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GetGlobalAdminsResponse : IApiResponse
|
||||
{
|
||||
public IReadOnlyList<object> Admins { get; set; } = default!;
|
||||
public IReadOnlyList<GlobalAdminRow> Admins { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of POST auth/global-admins (201): the created <c>GlobalAdminEntity</c>
|
||||
/// (backend-project type, serialized by runtime type).
|
||||
/// Response of POST auth/global-admins (201): the created row.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class AddGlobalAdminResponse : IApiResponse
|
||||
{
|
||||
public object Admin { get; set; } = default!;
|
||||
public GlobalAdminRow Admin { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the
|
||||
/// ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire
|
||||
/// carried are storage internals and were dropped from the contract (no consumer read them).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GlobalAdminRow
|
||||
{
|
||||
/// <summary>User Principal Name (lowercase).</summary>
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
public bool IsEnabled { get; set; }
|
||||
public DateTime AddedDate { get; set; }
|
||||
public string AddedBy { get; set; } = string.Empty;
|
||||
/// <summary>"GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin).</summary>
|
||||
public string Role { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,8 +103,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET health/detailed: the full system health report (always 200; per-check
|
||||
/// status is in the body). Items are <c>HealthCheck</c> objects (backend-project type,
|
||||
/// serialized by runtime type); non-GA callers get a filtered list.
|
||||
/// status is in the body). Non-GA callers get a filtered check list.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class DetailedHealthCheckResponse : IApiResponse
|
||||
@@ -63,21 +111,62 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Service { get; set; } = default!;
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string OverallStatus { get; set; } = default!;
|
||||
public IReadOnlyList<object> Checks { get; set; } = default!;
|
||||
public IReadOnlyList<HealthCheck> Checks { get; set; } = default!;
|
||||
public string Version { get; set; } = default!;
|
||||
public string CommitHash { get; set; } = default!;
|
||||
public DateTime BuildUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET health/mcp: the standalone MCP-server reachability probe. The check is
|
||||
/// a <c>HealthCheck</c> object (backend-project type, serialized by runtime type).
|
||||
/// Response of GET health/mcp: the standalone MCP-server reachability probe.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpHealthCheckResponse : IApiResponse
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public object Check { get; set; } = default!;
|
||||
public HealthCheck Check { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One health check result inside GET health/detailed / health/mcp. Details is a
|
||||
/// heterogeneous per-check bag by design (endpoint URLs only for Global Admins);
|
||||
/// a null Details key is omitted (WhenWritingNull).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class HealthCheck
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "unknown";
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public Dictionary<string, object>? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both).
|
||||
/// The four platform/delegated keys are emitted ONLY when the caller actually holds the
|
||||
/// tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists,
|
||||
/// and <see cref="IsGlobalAdmin"/> is only ever emitted as literal <c>true</c>.
|
||||
/// </summary>
|
||||
// Declaration order == wire order (matches the former Dictionary insertion order).
|
||||
public class CheckMcpAccessResponse : IApiResponse
|
||||
{
|
||||
public bool Allowed { get; set; }
|
||||
public string Upn { get; set; } = default!;
|
||||
public string AccessGrant { get; set; } = default!;
|
||||
public string Reason { get; set; } = default!;
|
||||
|
||||
/// <summary>Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false.</summary>
|
||||
public bool? IsGlobalAdmin { get; set; }
|
||||
|
||||
/// <summary>"GlobalAdmin" | "GlobalReader"; omitted without a platform role.</summary>
|
||||
public string? GlobalRole { get; set; }
|
||||
|
||||
/// <summary>Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise.</summary>
|
||||
public IReadOnlyCollection<string>? DelegatedTenantIds { get; set; }
|
||||
|
||||
/// <summary>"DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope.</summary>
|
||||
public string? DelegatedRole { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -143,23 +143,19 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant session-status tally envelope shared by MetricsSummary and
|
||||
/// MetricsSummaryGlobal. <see cref="Summary"/> items are repository-built rows shaped
|
||||
/// like <see cref="MetricsSummaryTenantItem"/>.
|
||||
/// MetricsSummaryGlobal.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class MetricsSummaryResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
[ProjectedItems(typeof(MetricsSummaryTenantItem))]
|
||||
public IReadOnlyList<object> Summary { get; set; } = default!;
|
||||
|
||||
public IReadOnlyList<MetricsSummaryTenantItem> Summary { get; set; } = default!;
|
||||
public int WindowDays { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape of one per-tenant status tally in <see cref="MetricsSummaryResponse"/>
|
||||
/// (documentation type — the repository still builds the rows; every key is always present).
|
||||
/// One per-tenant status tally in <see cref="MetricsSummaryResponse"/>. WindowDays is
|
||||
/// repeated per item (envelope carries it too — historical wire shape, kept for parity).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class MetricsSummaryTenantItem
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared response of GET global/notifications and GET notifications: the active
|
||||
/// (non-dismissed) notifications visible to the caller, newest first. Items are
|
||||
/// <c>GlobalNotificationDto</c> objects (backend-project type, serialized by runtime type).
|
||||
/// (non-dismissed) notifications visible to the caller, newest first.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class NotificationListResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public IReadOnlyList<object> Notifications { get; set; } = default!;
|
||||
public IReadOnlyList<GlobalNotificationDto> Notifications { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One in-app notification (global pool or tenant-scoped; both endpoints share this DTO).
|
||||
/// A null Href key is omitted (WhenWritingNull).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GlobalNotificationDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "info";
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string? Href { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -54,12 +54,93 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool Success { get; set; }
|
||||
public string SessionId { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>RuleDryRun</c> trace (AutopilotMonitor.Functions.Services) — typed
|
||||
/// <c>object</c> here only because that class lives in the Functions assembly;
|
||||
/// System.Text.Json serializes the runtime type, so the wire shape is unchanged.
|
||||
/// </summary>
|
||||
public object Result { get; set; } = default!;
|
||||
/// <summary>The full diagnostic trace of the evaluation.</summary>
|
||||
public RuleDryRun Result { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
|
||||
public static class RuleDryRunVerdict
|
||||
{
|
||||
public const string Fired = "fired";
|
||||
public const string SkippedByPrecondition = "skipped_by_precondition";
|
||||
public const string RequiredConditionNotMet = "required_condition_not_met";
|
||||
public const string NoConditionsMatched = "no_conditions_matched";
|
||||
public const string BelowConfidenceThreshold = "below_confidence_threshold";
|
||||
public const string NoEvents = "no_events";
|
||||
}
|
||||
|
||||
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
|
||||
// Declaration order == wire order.
|
||||
public sealed class RuleDryRun
|
||||
{
|
||||
public string Verdict { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Number of events in the session the rule was evaluated against.</summary>
|
||||
public int EventCount { get; set; }
|
||||
|
||||
public List<RuleDryRunPrecondition> Preconditions { get; } = new List<RuleDryRunPrecondition>();
|
||||
public List<RuleDryRunCondition> Conditions { get; } = new List<RuleDryRunCondition>();
|
||||
|
||||
/// <summary>Empty unless all required conditions were met (mirrors the production path,
|
||||
/// which never reaches factor evaluation otherwise).</summary>
|
||||
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new List<RuleDryRunFactor>();
|
||||
|
||||
public int BaseConfidence { get; set; }
|
||||
|
||||
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
|
||||
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
|
||||
public int? FinalConfidence { get; set; }
|
||||
|
||||
public int ConfidenceThreshold { get; set; }
|
||||
|
||||
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
|
||||
/// The dry-run itself never touches the session.</summary>
|
||||
public bool WouldMarkSessionAsFailed { get; set; }
|
||||
|
||||
/// <summary>The evidence map exactly as the production path would persist it on a
|
||||
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
|
||||
/// preview {{token}} interpolation of explanation/remediation. Values are heterogeneous
|
||||
/// evidence objects by design.</summary>
|
||||
public Dictionary<string, object>? MatchedConditions { get; set; }
|
||||
}
|
||||
|
||||
// Declaration order == wire order.
|
||||
public sealed class RuleDryRunPrecondition
|
||||
{
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string? DataField { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public bool Passed { get; set; }
|
||||
}
|
||||
|
||||
// Declaration order == wire order.
|
||||
public sealed class RuleDryRunCondition
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? EventType { get; set; }
|
||||
public bool Required { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
|
||||
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
|
||||
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
|
||||
public object? Evidence { get; set; }
|
||||
|
||||
/// <summary>How many session events have this condition's eventType at all — the first
|
||||
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
|
||||
/// condition has no eventType.</summary>
|
||||
public int? MatchingEventCount { get; set; }
|
||||
}
|
||||
|
||||
// Declaration order == wire order.
|
||||
public sealed class RuleDryRunFactor
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
public int Weight { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -151,12 +232,18 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// Declaration order == wire order.
|
||||
public class GetPreviewWhitelistResponse : IApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>PreviewWhitelistEntity</c> rows (AutopilotMonitor.Functions.DataAccess.TableStorage)
|
||||
/// — element-typed <c>object</c> here only because that entity lives in the Functions
|
||||
/// assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<object> Tenants { get; set; } = default!;
|
||||
public IReadOnlyList<PreviewWhitelistTenantEntry> Tenants { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31
|
||||
/// wire carried synthetic <c>PreviewWhitelistEntity</c> rows whose only real datum was the
|
||||
/// tenant id in <c>partitionKey</c> (plus garbage defaults) — the contract is now just the id.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class PreviewWhitelistTenantEntry
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import type { AdminConfiguration, OpsAlertRule } from "@/types/adminConfig";
|
||||
import type { GetPreviewWhitelistResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
// Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working
|
||||
export type { AdminConfiguration, OpsAlertRule };
|
||||
@@ -297,9 +298,9 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
setTenants(data);
|
||||
|
||||
if (previewRes.ok) {
|
||||
const previewData = await previewRes.json();
|
||||
const previewData = (await previewRes.json()) as GetPreviewWhitelistResponse;
|
||||
const approvedIds = new Set<string>(
|
||||
(previewData.tenants || []).map((t: { partitionKey: string }) => t.partitionKey)
|
||||
(previewData.tenants || []).map((t) => t.tenantId)
|
||||
);
|
||||
setPreviewApproved(approvedIds);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,10 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
import type { TenantAdminRow } from "@/utils/wire-types.generated";
|
||||
|
||||
interface TenantAdmin {
|
||||
tenantId: string;
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
role: string | null;
|
||||
canManageBootstrapTokens: boolean;
|
||||
}
|
||||
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
|
||||
type TenantAdmin = TenantAdminRow;
|
||||
|
||||
interface TenantAdminSectionProps {
|
||||
tenantId: string;
|
||||
|
||||
@@ -8,31 +8,14 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
|
||||
interface HealthCheck {
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
service: string;
|
||||
timestamp: string;
|
||||
overallStatus: string;
|
||||
checks: HealthCheck[];
|
||||
version?: string;
|
||||
commitHash?: string;
|
||||
buildUtc?: string;
|
||||
}
|
||||
import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated';
|
||||
|
||||
export default function HealthCheckPage() {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR();
|
||||
const { tenantId } = useTenant();
|
||||
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
|
||||
const [healthResult, setHealthResult] = useState<DetailedHealthCheckResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
const [groupJoinTest, setGroupJoinTest] = useState<'idle' | 'testing' | 'success' | 'failed'>('idle');
|
||||
@@ -60,7 +43,7 @@ export default function HealthCheckPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DetailedHealthCheckResponse;
|
||||
setHealthResult(data);
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
@@ -94,8 +77,8 @@ export default function HealthCheckPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data?.check) setMcpCheck(data.check as HealthCheck);
|
||||
const data = (await response.json()) as McpHealthCheckResponse;
|
||||
if (data?.check) setMcpCheck(data.check);
|
||||
} catch (error) {
|
||||
// Network/token errors: surface on the card itself, never as a blocking page error.
|
||||
setMcpCheck({
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AdminManagementSectionProps {
|
||||
onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void;
|
||||
}
|
||||
|
||||
function getRoleBadge(role: string | null) {
|
||||
function getRoleBadge(role: string | null | undefined) {
|
||||
const effectiveRole = role ?? "Admin";
|
||||
switch (effectiveRole) {
|
||||
case "Admin":
|
||||
|
||||
@@ -139,12 +139,5 @@ export interface TenantConfiguration {
|
||||
trialConsumed?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantAdmin {
|
||||
tenantId: string;
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
role: string | null;
|
||||
canManageBootstrapTokens: boolean;
|
||||
}
|
||||
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
|
||||
export type { TenantAdminRow as TenantAdmin } from "@/utils/wire-types.generated";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/lib/authApp';
|
||||
import { api } from '@/lib/api';
|
||||
import { trackEvent } from '@/lib/appInsights';
|
||||
import type { AuthMeResponse } from '@/types/auth';
|
||||
|
||||
// Initialize MSAL instance for the ACTIVE app registration (dual app-reg window: the
|
||||
// selection was decided at module boot in lib/msalConfig.ts; app switches always reload).
|
||||
@@ -33,7 +34,7 @@ let msalReady = false;
|
||||
// fetch failed / token acquisition failed.
|
||||
// Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise,
|
||||
// otherwise a cold backend would keep the UI on a white screen.
|
||||
let prefetchedAuthMePromise: Promise<Record<string, unknown> | null> | null = null;
|
||||
let prefetchedAuthMePromise: Promise<AuthMeResponse | null> | null = null;
|
||||
|
||||
/**
|
||||
* Safety net of the dual app-reg model, for the rare browser that lands on an app its tenant
|
||||
@@ -108,7 +109,7 @@ const msalInitPromise = msalInstance
|
||||
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
return res.ok ? (await res.json()) as Record<string, unknown> : null;
|
||||
return res.ok ? (await res.json()) as AuthMeResponse : null;
|
||||
}).catch(() => null);
|
||||
}
|
||||
})
|
||||
@@ -158,33 +159,23 @@ async function safeAcquireTokenRedirect(
|
||||
}
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
displayName: string;
|
||||
upn: string;
|
||||
tenantId: string;
|
||||
objectId: string;
|
||||
isGlobalAdmin: boolean;
|
||||
/**
|
||||
* Read-only platform tier: cross-tenant VISIBILITY like a Global Admin but no platform mutations.
|
||||
* ADDITIVE — a user may be both a GlobalReader and their own tenant's Admin (then isTenantAdmin is
|
||||
* also true and they keep edit rights on their own tenant). Use {@link AuthContextType.hasGlobalScope}
|
||||
* for visibility gating and isGlobalAdmin for platform-mutation gating.
|
||||
*/
|
||||
isGlobalReader: boolean;
|
||||
isTenantAdmin: boolean;
|
||||
/**
|
||||
* Delegated ("MSP") admin: the caller manages a SUBSET of OTHER tenants (read-only this phase) — the
|
||||
* "scoped global" tier between a single-tenant member and a platform Global Admin. True iff
|
||||
* {@link delegatedTenantIds} is non-empty. Use {@link AuthContextType.hasFleetScope} for fleet/switcher gating.
|
||||
*/
|
||||
isDelegated: boolean;
|
||||
/** The tenant IDs this caller manages as a delegated admin (lowercase). Empty for non-delegated users. */
|
||||
delegatedTenantIds: string[];
|
||||
/**
|
||||
* Client-side auth state, derived from the generated auth/me wire type — a backend key
|
||||
* rename goes tsc-red here instead of silently degrading to fallbacks.
|
||||
* Deviations from the wire: `role` is an explicit null (the wire omits the key for a
|
||||
* roleless caller), and `homedApp` is not carried (it is learned into localStorage via
|
||||
* {@link learnHomedAppFromAuthMe}, never React state).
|
||||
*
|
||||
* Semantics reminders:
|
||||
* - isGlobalReader: read-only platform tier — cross-tenant VISIBILITY like a Global Admin
|
||||
* but no platform mutations. ADDITIVE (a GlobalReader can also be their own tenant's
|
||||
* Admin). Use {@link AuthContextType.hasGlobalScope} for visibility gating and
|
||||
* isGlobalAdmin for platform-mutation gating.
|
||||
* - isDelegated / delegatedTenantIds: delegated ("MSP") admin managing a SUBSET of other
|
||||
* tenants — use {@link AuthContextType.hasFleetScope} for fleet/switcher gating.
|
||||
*/
|
||||
interface UserInfo extends Omit<AuthMeResponse, 'role' | 'homedApp'> {
|
||||
role: 'Admin' | 'Operator' | 'Viewer' | null;
|
||||
canManageBootstrapTokens: boolean;
|
||||
hasMcpAccess: boolean;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -214,7 +205,7 @@ interface AuthContextType {
|
||||
* This is also how an operator flip propagates: flip → next login of every user runs via the
|
||||
* new app, at which point the tenant's admin consent already exists.
|
||||
*/
|
||||
function learnHomedAppFromAuthMe(data: Record<string, unknown>): void {
|
||||
function learnHomedAppFromAuthMe(data: AuthMeResponse): void {
|
||||
const homedApp = data.homedApp;
|
||||
if (homedApp === 'legacy' || homedApp === 'primary') {
|
||||
// Change-only trace: this browser just learned its tenant was flipped — the propagation
|
||||
@@ -226,6 +217,30 @@ function learnHomedAppFromAuthMe(data: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a successful auth/me body onto client auth state. Token-claim fallbacks cover the
|
||||
* cold-start edge where a field arrives empty; the shape itself is compiler-checked
|
||||
* against the generated wire type.
|
||||
*/
|
||||
function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
|
||||
return {
|
||||
displayName: data.displayName || account.name || '',
|
||||
upn: data.upn || account.username || '',
|
||||
tenantId: data.tenantId || account.tenantId || '',
|
||||
objectId: data.objectId || account.homeAccountId || '',
|
||||
isGlobalAdmin: data.isGlobalAdmin || false,
|
||||
isGlobalReader: data.isGlobalReader || false,
|
||||
isTenantAdmin: data.isTenantAdmin || false,
|
||||
isDelegated: data.isDelegated || false,
|
||||
delegatedTenantIds: data.delegatedTenantIds || [],
|
||||
role: data.role ?? null,
|
||||
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
|
||||
hasMcpAccess: data.hasMcpAccess || false,
|
||||
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
|
||||
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
|
||||
};
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
@@ -255,22 +270,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
prefetchedAuthMePromise = null;
|
||||
const data = await pending;
|
||||
if (data) learnHomedAppFromAuthMe(data);
|
||||
if (data) return {
|
||||
displayName: (data.displayName as string) || account.name || '',
|
||||
upn: (data.upn as string) || account.username || '',
|
||||
tenantId: (data.tenantId as string) || account.tenantId || '',
|
||||
objectId: (data.objectId as string) || account.homeAccountId || '',
|
||||
isGlobalAdmin: (data.isGlobalAdmin as boolean) || false,
|
||||
isGlobalReader: (data.isGlobalReader as boolean) || false,
|
||||
isTenantAdmin: (data.isTenantAdmin as boolean) || false,
|
||||
isDelegated: (data.isDelegated as boolean) || false,
|
||||
delegatedTenantIds: (data.delegatedTenantIds as string[]) || [],
|
||||
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
|
||||
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
|
||||
hasMcpAccess: (data.hasMcpAccess as boolean) || false,
|
||||
bootstrapTokenEnabled: (data.bootstrapTokenEnabled as boolean) || false,
|
||||
unrestrictedModeEnabled: (data.unrestrictedModeEnabled as boolean) || false,
|
||||
};
|
||||
if (data) return toUserInfo(data, account);
|
||||
}
|
||||
|
||||
// Get access token for API
|
||||
@@ -334,31 +334,16 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as AuthMeResponse;
|
||||
|
||||
// A successful auth/me means the tenant is (now) activated — clear any pending
|
||||
// state so the activation page's poll can redirect into the portal.
|
||||
setActivationPending(false);
|
||||
setActivationMessage('');
|
||||
|
||||
learnHomedAppFromAuthMe(data as Record<string, unknown>);
|
||||
learnHomedAppFromAuthMe(data);
|
||||
|
||||
return {
|
||||
displayName: data.displayName || account.name || '',
|
||||
upn: data.upn || account.username || '',
|
||||
tenantId: data.tenantId || account.tenantId || '',
|
||||
objectId: data.objectId || account.homeAccountId || '',
|
||||
isGlobalAdmin: data.isGlobalAdmin || false,
|
||||
isGlobalReader: data.isGlobalReader || false,
|
||||
isTenantAdmin: data.isTenantAdmin || false,
|
||||
isDelegated: data.isDelegated || false,
|
||||
delegatedTenantIds: data.delegatedTenantIds || [],
|
||||
role: data.role || null,
|
||||
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
|
||||
hasMcpAccess: data.hasMcpAccess || false,
|
||||
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
|
||||
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
|
||||
};
|
||||
return toUserInfo(data, account);
|
||||
} catch (error) {
|
||||
// If the refresh token is expired or consent is required, redirect to
|
||||
// interactive login immediately instead of falling back to stale claims.
|
||||
|
||||
@@ -5,15 +5,10 @@ import { useAuth } from './AuthContext';
|
||||
import { useSignalR } from './SignalRContext';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated';
|
||||
|
||||
export interface GlobalNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
href?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
// Wire type is generated from the backend DTO; the alias keeps the established local name.
|
||||
export type GlobalNotification = GlobalNotificationDto;
|
||||
|
||||
interface GlobalNotificationContextType {
|
||||
notifications: GlobalNotification[];
|
||||
@@ -51,7 +46,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
getAccessToken,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as NotificationListResponse;
|
||||
setNotifications(data.notifications ?? []);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -6,15 +6,10 @@ import { useSignalR } from './SignalRContext';
|
||||
import { canFetchTenantNotifications } from './tenantNotificationsGate';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated';
|
||||
|
||||
export interface TenantNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
href?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
// Both notification endpoints share one backend DTO; the alias keeps the established local name.
|
||||
export type TenantNotification = GlobalNotificationDto;
|
||||
|
||||
interface TenantNotificationContextType {
|
||||
tenantNotifications: TenantNotification[];
|
||||
@@ -54,7 +49,7 @@ export function TenantNotificationProvider({ children }: { children: React.React
|
||||
getAccessToken,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as NotificationListResponse;
|
||||
setTenantNotifications(data.notifications ?? []);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// The auth/me wire shape is GENERATED from the C# contract (AuthMeResponse in
|
||||
// AutopilotMonitor.Shared → utils/wire-types.generated.ts). This module narrows the
|
||||
// two plain-string wire slots to the canonical literals the backend actually emits,
|
||||
// so a backend rename or type change goes tsc-red in every consumer.
|
||||
import type { AuthMeResponse as WireAuthMeResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
/** Tenant role emitted by auth/me ("role" is absent on the wire for a roleless caller). */
|
||||
export type TenantRole = "Admin" | "Operator" | "Viewer";
|
||||
|
||||
/** Which app registration the tenant is homed on (dual app-reg operation model). */
|
||||
export type HomedApp = "primary" | "legacy";
|
||||
|
||||
export type AuthMeResponse = Omit<WireAuthMeResponse, "role" | "homedApp"> & {
|
||||
role?: TenantRole;
|
||||
homedApp: HomedApp;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,9 @@ export interface ActiveUserItem {
|
||||
secondsAgo: number;
|
||||
}
|
||||
|
||||
/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */
|
||||
/** Response of POST auth/global-admins (201): the created row. */
|
||||
export interface AddGlobalAdminResponse {
|
||||
admin: unknown;
|
||||
admin: GlobalAdminRow;
|
||||
}
|
||||
|
||||
/** Response of POST vulnerability/ignored-software: how many entries were added. */
|
||||
@@ -540,6 +540,29 @@ export interface AuditLogListResponse {
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */
|
||||
export interface AuthMeResponse {
|
||||
tenantId: string;
|
||||
upn: string;
|
||||
displayName: string;
|
||||
objectId: string;
|
||||
isGlobalAdmin: boolean;
|
||||
isGlobalReader: boolean;
|
||||
isTenantAdmin: boolean;
|
||||
/** True when the caller holds delegated ("MSP") assignments to other tenants. */
|
||||
isDelegated: boolean;
|
||||
/** The OTHER tenants this caller may manage; empty for non-delegated callers. */
|
||||
delegatedTenantIds: string[];
|
||||
/** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */
|
||||
role?: string;
|
||||
canManageBootstrapTokens: boolean;
|
||||
hasMcpAccess: boolean;
|
||||
/** "primary" or "legacy" — which app registration this tenant is homed on. */
|
||||
homedApp: string;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */
|
||||
export interface AutoResolveCpeMappingResponse {
|
||||
resolved: AutoResolveResultItem[];
|
||||
@@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse {
|
||||
willAutoFlipHoming: boolean;
|
||||
}
|
||||
|
||||
/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */
|
||||
export interface BackfillJobRunResponse {
|
||||
success: boolean;
|
||||
result: BackfillResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */
|
||||
export interface BackfillResult {
|
||||
table: string;
|
||||
dryRun: boolean;
|
||||
rowsExamined: number;
|
||||
wouldWrite: number;
|
||||
written: number;
|
||||
skippedAlreadySet: number;
|
||||
skippedUndecodable: number;
|
||||
errors: number;
|
||||
nextContinuation?: string;
|
||||
samples: BackfillSample[];
|
||||
}
|
||||
|
||||
/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */
|
||||
export interface BackfillSample {
|
||||
partitionKey: string;
|
||||
rowKey: string;
|
||||
decodedUtc: string;
|
||||
}
|
||||
|
||||
/** Response of POST devices/block: block/kill acknowledgement. */
|
||||
export interface BlockDeviceResponse {
|
||||
success: boolean;
|
||||
@@ -648,6 +700,22 @@ export interface BlockingAppInterval {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */
|
||||
export interface CheckMcpAccessResponse {
|
||||
allowed: boolean;
|
||||
upn: string;
|
||||
accessGrant: string;
|
||||
reason: string;
|
||||
/** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */
|
||||
isGlobalAdmin?: boolean;
|
||||
/** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */
|
||||
globalRole?: string;
|
||||
/** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */
|
||||
delegatedTenantIds?: string[];
|
||||
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
|
||||
delegatedRole?: string;
|
||||
}
|
||||
|
||||
/** A factor that increases confidence when matched */
|
||||
export interface ConfidenceFactor {
|
||||
/** Descriptive name for this factor */
|
||||
@@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics {
|
||||
whiteGlovePercentage: number;
|
||||
}
|
||||
|
||||
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */
|
||||
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */
|
||||
export interface DetailedHealthCheckResponse {
|
||||
service: string;
|
||||
timestamp: string;
|
||||
overallStatus: string;
|
||||
checks: unknown[];
|
||||
checks: HealthCheck[];
|
||||
version: string;
|
||||
commitHash: string;
|
||||
buildUtc: string;
|
||||
@@ -1062,8 +1130,8 @@ export interface DistressReportListResponse {
|
||||
export interface DryRunAnalyzeRuleResponse {
|
||||
success: boolean;
|
||||
sessionId: string;
|
||||
/** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
|
||||
result: unknown;
|
||||
/** The full diagnostic trace of the evaluation. */
|
||||
result: RuleDryRun;
|
||||
}
|
||||
|
||||
export interface EfficiencyOffender {
|
||||
@@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse {
|
||||
attemptNumber?: number;
|
||||
}
|
||||
|
||||
/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */
|
||||
/** Response of GET auth/global-admins: every Global Admin/Reader row. */
|
||||
export interface GetGlobalAdminsResponse {
|
||||
admins: unknown[];
|
||||
admins: GlobalAdminRow[];
|
||||
}
|
||||
|
||||
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
|
||||
@@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse {
|
||||
|
||||
/** Response of GET preview/whitelist: every approved tenant (Global Admin only). */
|
||||
export interface GetPreviewWhitelistResponse {
|
||||
/** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
|
||||
tenants: unknown[];
|
||||
tenants: PreviewWhitelistTenantEntry[];
|
||||
}
|
||||
|
||||
/** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */
|
||||
@@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse {
|
||||
export interface GetTenantConfigFieldsSchemaResponse {
|
||||
count: number;
|
||||
writableCount: number;
|
||||
/** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */
|
||||
fields: unknown[];
|
||||
fields: TenantConfigFieldSchema[];
|
||||
}
|
||||
|
||||
/** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */
|
||||
@@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse {
|
||||
epssLastRun?: EpssRefreshRunWire;
|
||||
}
|
||||
|
||||
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
|
||||
export interface GlobalAdminRow {
|
||||
/** User Principal Name (lowercase). */
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
/** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Global average benchmarks for geographic comparison. */
|
||||
export interface GlobalAverages {
|
||||
avgDurationMinutes: number;
|
||||
@@ -1713,6 +1790,16 @@ export interface GlobalAverages {
|
||||
totalDoBytesFromHttp: number;
|
||||
}
|
||||
|
||||
/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */
|
||||
export interface GlobalNotificationDto {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
href?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */
|
||||
export interface GraphFeatureStatusItem {
|
||||
name: string;
|
||||
@@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse {
|
||||
dataQualityNotice: string;
|
||||
}
|
||||
|
||||
/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */
|
||||
export interface HealthCheck {
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Response of GET health: liveness probe plus the backend build identity. */
|
||||
export interface HealthCheckResponse {
|
||||
status: string;
|
||||
@@ -2100,19 +2196,26 @@ export interface LogPatternTestResult {
|
||||
readonly notes: string[];
|
||||
}
|
||||
|
||||
/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */
|
||||
export interface MaintenanceJobRunResponse {
|
||||
/** Run report of a manual maintenance run (POST maintenance/trigger). */
|
||||
export interface MaintenanceResult {
|
||||
success: boolean;
|
||||
/** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
result: unknown;
|
||||
error?: string;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
durationMs: number;
|
||||
stalledSessionsChecked: boolean;
|
||||
metricsAggregated: boolean;
|
||||
aggregatedDate?: string;
|
||||
dataCleanupExecuted: boolean;
|
||||
platformStatsRecomputed: boolean;
|
||||
devicesBlockedForExcessiveData: number;
|
||||
contactEmailsBackfilled: number;
|
||||
}
|
||||
|
||||
/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */
|
||||
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
|
||||
export interface McpHealthCheckResponse {
|
||||
timestamp: string;
|
||||
check: unknown;
|
||||
check: HealthCheck;
|
||||
}
|
||||
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
|
||||
@@ -2147,14 +2250,14 @@ export interface McpUserEntry {
|
||||
usagePlan?: string;
|
||||
}
|
||||
|
||||
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */
|
||||
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */
|
||||
export interface MetricsSummaryResponse {
|
||||
success: boolean;
|
||||
summary: Partial<MetricsSummaryTenantItem>[];
|
||||
summary: MetricsSummaryTenantItem[];
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */
|
||||
/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */
|
||||
export interface MetricsSummaryTenantItem {
|
||||
tenantId: string;
|
||||
totalSessions: number;
|
||||
@@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem {
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */
|
||||
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */
|
||||
export interface NotificationListResponse {
|
||||
success: boolean;
|
||||
notifications: unknown[];
|
||||
notifications: GlobalNotificationDto[];
|
||||
}
|
||||
|
||||
/** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */
|
||||
@@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics {
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */
|
||||
export interface PreviewWhitelistTenantEntry {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
|
||||
export interface ProgressGetSessionEventsResponse {
|
||||
success: boolean;
|
||||
@@ -2403,6 +2511,40 @@ export interface RebootSpan {
|
||||
segmentKey: string;
|
||||
}
|
||||
|
||||
/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */
|
||||
export interface ReclassificationResult {
|
||||
mode: string;
|
||||
dryRun: boolean;
|
||||
tenantsExamined: number;
|
||||
sessionsExamined: number;
|
||||
wouldChange: number;
|
||||
changed: number;
|
||||
toSucceeded: number;
|
||||
toIncomplete: number;
|
||||
keptFailed: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
/** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */
|
||||
capReached: boolean;
|
||||
readonly samples: ReclassificationSample[];
|
||||
}
|
||||
|
||||
export interface ReclassificationSample {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */
|
||||
export interface ReclassifyJobRunResponse {
|
||||
success: boolean;
|
||||
result: ReclassificationResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */
|
||||
export interface ReducerVerificationReport {
|
||||
tenantId: string;
|
||||
@@ -2530,6 +2672,53 @@ export interface RuleCondition {
|
||||
eventAFilterValue: string;
|
||||
}
|
||||
|
||||
/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */
|
||||
export interface RuleDryRun {
|
||||
verdict: string;
|
||||
/** Number of events in the session the rule was evaluated against. */
|
||||
eventCount: number;
|
||||
readonly preconditions: RuleDryRunPrecondition[];
|
||||
readonly conditions: RuleDryRunCondition[];
|
||||
/** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */
|
||||
readonly confidenceFactors: RuleDryRunFactor[];
|
||||
baseConfidence: number;
|
||||
/** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */
|
||||
finalConfidence?: number;
|
||||
confidenceThreshold: number;
|
||||
/** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */
|
||||
wouldMarkSessionAsFailed: boolean;
|
||||
/** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */
|
||||
matchedConditions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RuleDryRunCondition {
|
||||
signal: string;
|
||||
source: string;
|
||||
eventType?: string;
|
||||
required: boolean;
|
||||
matched: boolean;
|
||||
/** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */
|
||||
evidence?: unknown;
|
||||
/** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */
|
||||
matchingEventCount?: number;
|
||||
}
|
||||
|
||||
export interface RuleDryRunFactor {
|
||||
signal: string;
|
||||
condition: string;
|
||||
weight: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface RuleDryRunPrecondition {
|
||||
source: string;
|
||||
eventType: string;
|
||||
dataField?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
|
||||
export interface RulePrecondition {
|
||||
/** Source of the fact. Currently only event_data. */
|
||||
@@ -3223,8 +3412,22 @@ export interface TemplateVariable {
|
||||
|
||||
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
|
||||
export interface TenantAdminCreatedResponse {
|
||||
/** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
admin: unknown;
|
||||
admin: TenantAdminRow;
|
||||
}
|
||||
|
||||
/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
|
||||
export interface TenantAdminRow {
|
||||
/** Tenant ID (lowercase). */
|
||||
tenantId: string;
|
||||
/** User Principal Name (lowercase). */
|
||||
upn: string;
|
||||
isEnabled: boolean;
|
||||
addedDate: string;
|
||||
addedBy: string;
|
||||
/** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */
|
||||
role?: string;
|
||||
/** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */
|
||||
canManageBootstrapTokens: boolean;
|
||||
}
|
||||
|
||||
/** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */
|
||||
@@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem {
|
||||
diff?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */
|
||||
export interface TenantConfigFieldSchema {
|
||||
name: string;
|
||||
type: string;
|
||||
format?: string;
|
||||
nullable: boolean;
|
||||
writable: boolean;
|
||||
reason?: string;
|
||||
gaOnly: boolean;
|
||||
revertProtected: boolean;
|
||||
}
|
||||
|
||||
/** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */
|
||||
export interface TenantConfigPatchOutcomeResponse {
|
||||
success: boolean;
|
||||
@@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse {
|
||||
export interface TriggerMaintenanceResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
|
||||
result: unknown;
|
||||
result: MaintenanceResult;
|
||||
triggeredBy: string;
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user