Expose resource policy posture aggregation

This commit is contained in:
rcourtman 2026-04-25 18:46:10 +01:00
parent bfcdfa7699
commit 361b921b91
15 changed files with 370 additions and 22 deletions

View file

@ -0,0 +1,24 @@
# Policy-Aware Data Governance Resource Aggregations - 2026-04-25
Status: partial slice complete; the policy-aware data governance lane is not complete.
## Scope
This slice promotes the existing unified resource policy posture out of AI-only usage and into the canonical resource API contract.
## Implemented
- `internal/unifiedresources` now owns a camelCase resource API policy posture contract derived from the canonical `PolicyPostureSummary`.
- `/api/resources` and `/api/resources/stats` expose `policyPosture` alongside existing resource aggregations.
- Empty resources responses normalize policy posture maps to `{}` instead of `null`.
- `frontend-modern/src/hooks/useUnifiedResources.ts` exposes `policyPosture()` from the canonical resources API so future UI surfaces do not need to depend on AI summary payloads for estate policy posture.
## Proof
- `go test ./internal/unifiedresources ./internal/api -run 'TestSummarizePolicyPosture|TestResourcePolicyPostureContractUsesCamelCaseNonNullCollections|TestContract_ResourceListPolicyMetadata|TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding|TestContract_ResourceListCarriesTimelineAndCapabilityContracts|TestResourceAndStorageResponsesUseCanonicalEmptyCollections' -count=1`
- `npm --prefix frontend-modern test -- src/hooks/__tests__/useUnifiedResources.test.ts`
- `npm --prefix frontend-modern run type-check`
## Remaining Gap
The lane still needs a full governed product model for policy-aware data governance across Pulse and Pulse Enterprise, including customer-facing policy controls, cloud routing boundaries, enterprise audit semantics, and explicit GA proof once those surfaces exist.

View file

@ -177,6 +177,10 @@ an add-only capacity posture.
order instead of inheriting map order or page-local re-sorts, so install
and runtime hydration do not present one resource ordering at first load and
a different ordering after the first live refresh.
Those lifecycle-adjacent reads may observe resource API
`policyPosture` aggregation as read-only data-governance context, but they
must not reinterpret sensitivity, routing, or redaction counts as install
capacity, registration eligibility, or agent assignment state.
When lifecycle surfaces also hydrate from `/api/state`, that first-session
snapshot must carry the same canonical resource types and display names as
`/api/resources` instead of briefly showing legacy host aliases before the

View file

@ -206,6 +206,11 @@ the canonical monitored-system blocked payload.
Websocket-backed API consumers such as `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` may read runtime context only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx`, because payload ownership remains in the API contract rather than the root shell.
3. Add dedicated contract tests for new stable payloads
4. Route unified resource sensitivity, routing, and `aiSafeSummary` payload changes through `internal/api/resources.go`, `internal/api/contract_test.go`, and the canonical frontend resource consumer proofs together; resource governance metadata must not ship as an API-only or frontend-only heuristic
That same resource payload contract owns `aggregations.policyPosture` on
`/api/resources` and `/api/resources/stats`. The aggregation must be derived
from canonical unified-resource policy metadata, normalized as camelCase
resource API JSON, and exercised with backend contract tests plus the
canonical `useUnifiedResources` frontend hook proof whenever it changes.
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection
7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts

View file

@ -172,6 +172,10 @@ querying, and the operator-facing storage health presentation layer.
`ResourceType` normalization for route/query filters, so storage subtypes
such as `physical_disk` stay on the same cache-backed snapshot instead of
relying on storage-local filter aliases.
Storage and recovery consumers that need estate data-governance posture
must read the hook's resource API-backed `policyPosture()` accessor rather
than deriving sensitivity, routing, or redaction counts from storage-local
tables, AI summary payloads, or route filters.
Optional selector shells that only surface storage/recovery counts when they
are visible must now pass an explicit enabled gate into that shared hook and
any adjacent recovery-rollup query, so hidden workload-route selectors do

View file

@ -101,6 +101,7 @@ cross-source deduplication.
79. `frontend-modern/src/types/resource.ts`
80. `frontend-modern/src/utils/sourcePlatforms.ts`
81. `internal/unifiedresources/kubernetes_metric_ids.go`
82. `internal/unifiedresources/policy_posture.go`
## Shared Boundaries
@ -134,6 +135,11 @@ The canonical AI-safe summary builder now owns the sensitivity-specific suffix
phrases for `sensitive` and `restricted` resources, so the backend policy
contract controls those strings instead of duplicating them inside the summary
assembly branch.
Canonical policy posture aggregation is owned here as well. Resource API
payloads may expose a camelCase transport projection, but the counts must be
derived from `internal/unifiedresources/policy_posture.go` after canonical
policy metadata has been refreshed, not recomputed from frontend labels,
AI-only summary payloads, or page-local heuristics.
4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go`
5. Add platform registry, resolution, host-dedup, or monitored-system
projection behavior through `internal/unifiedresources/registry.go`,

View file

@ -553,7 +553,9 @@ describe('useUnifiedResources', () => {
readRate: 1_250_000,
writeRate: 640_000,
});
expect(result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs).toEqual(
expect(
result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs,
).toEqual(
expect.objectContaining({
datastoreCount: 2,
backupJobCount: 4,
@ -586,7 +588,9 @@ describe('useUnifiedResources', () => {
readRate: 1_250_000,
writeRate: 640_000,
});
expect(result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs).toEqual(
expect(
result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs,
).toEqual(
expect.objectContaining({
datastoreCount: 2,
backupJobCount: 4,
@ -757,6 +761,59 @@ describe('useUnifiedResources', () => {
dispose();
});
it('passes resource policy posture aggregations through unchanged', async () => {
apiFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [v2Resource],
aggregations: {
policyPosture: {
totalResources: 3,
sensitivityCounts: {
restricted: 1,
sensitive: 2,
},
routingCounts: {
'local-only': 1,
'local-first': 2,
},
redactionCounts: {
hostname: 1,
'ip-address': 1,
},
},
},
}),
});
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources();
});
await waitForValue(() => result!.policyPosture()?.totalResources, 3);
expect(apiFetchMock).toHaveBeenCalledTimes(1);
expect(result!.policyPosture()).toEqual({
totalResources: 3,
sensitivityCounts: {
restricted: 1,
sensitive: 2,
},
routingCounts: {
'local-only': 1,
'local-first': 2,
},
redactionCounts: {
hostname: 1,
'ip-address': 1,
},
});
dispose();
});
it('normalizes legacy k8s discovery targets to pod for frontend consumers', async () => {
apiFetchMock.mockResolvedValue({
ok: true,
@ -1553,7 +1610,7 @@ describe('useUnifiedResources', () => {
expect(useUnifiedResourcesSource).toContain('supportsCanonicalWsHydration');
expect(useUnifiedResourcesSource).toContain("initialHydration === 'prefer-ws'");
expect(useUnifiedResourcesSource).toContain('wsStore.state.resources');
expect(useUnifiedResourcesSource).not.toContain('const DEFAULT_ORG_SCOPE = \'default\'');
expect(useUnifiedResourcesSource).not.toContain("const DEFAULT_ORG_SCOPE = 'default'");
expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope =');
expect(useUnifiedResourcesSource).toContain('asTrimmedString');
expect(useUnifiedResourcesSource).not.toContain(

View file

@ -13,6 +13,7 @@ import type {
ResourceDiscoveryTarget,
ResourceMetricsTarget,
ResourcePBSMeta,
ResourcePolicyPostureSummary,
ResourceStatus,
ResourceStorageMeta,
ResourceType,
@ -442,10 +443,26 @@ type APIListResponse = {
meta?: {
totalPages?: number;
};
aggregations?: {
policyPosture?: APIResourcePolicyPostureSummary;
};
};
type APIResourcePolicyPostureSummary = {
totalResources?: number;
sensitivityCounts?: Partial<Record<string, number>>;
routingCounts?: Partial<Record<string, number>>;
redactionCounts?: Partial<Record<string, number>>;
};
type UnifiedResourcesSnapshot = {
resources: Resource[];
policyPosture: ResourcePolicyPostureSummary | null;
};
type UnifiedResourcesCacheEntry = {
resources: Resource[];
policyPosture: ResourcePolicyPostureSummary | null;
hasSnapshot: boolean;
cachedAt: number;
lastFetchAt: number;
@ -704,12 +721,57 @@ const buildUnifiedResourcesUrl = (query: string, page: number): string => {
return `${UNIFIED_RESOURCES_BASE_URL}?${params.toString()}`;
};
const resolveResourcesPayload = (payload: unknown): { data: APIResource[]; totalPages: number } => {
const normalizeNonNegativeCount = (value: unknown): number | undefined => {
const count = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(count)) {
return undefined;
}
return Math.max(0, Math.trunc(count));
};
const normalizeCountMap = <T extends string>(value: unknown): Partial<Record<T, number>> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const counts: Partial<Record<T, number>> = {};
for (const [key, rawCount] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = asTrimmedString(key);
const normalizedCount = normalizeNonNegativeCount(rawCount);
if (normalizedKey && normalizedCount !== undefined) {
counts[normalizedKey as T] = normalizedCount;
}
}
return counts;
};
const normalizeResourcePolicyPosture = (
value?: APIResourcePolicyPostureSummary | null,
): ResourcePolicyPostureSummary | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return {
totalResources: normalizeNonNegativeCount(value.totalResources) ?? 0,
sensitivityCounts: normalizeCountMap(value.sensitivityCounts),
routingCounts: normalizeCountMap(value.routingCounts),
redactionCounts: normalizeCountMap(value.redactionCounts),
};
};
const resolveResourcesPayload = (
payload: unknown,
): {
data: APIResource[];
totalPages: number;
policyPosture: ResourcePolicyPostureSummary | null;
} => {
if (Array.isArray(payload)) {
return { data: payload as APIResource[], totalPages: 1 };
return { data: payload as APIResource[], totalPages: 1, policyPosture: null };
}
if (!payload || typeof payload !== 'object') {
return { data: [], totalPages: 1 };
return { data: [], totalPages: 1, policyPosture: null };
}
const record = payload as APIListResponse;
const data = Array.isArray(record.data)
@ -720,7 +782,11 @@ const resolveResourcesPayload = (payload: unknown): { data: APIResource[]; total
const totalPages = Number.isFinite(record.meta?.totalPages)
? Math.max(1, Number(record.meta?.totalPages))
: 1;
return { data, totalPages };
return {
data,
totalPages,
policyPosture: normalizeResourcePolicyPosture(record.aggregations?.policyPosture),
};
};
const dedupeResources = (resources: APIResource[]): APIResource[] => {
@ -741,6 +807,7 @@ const getUnifiedResourcesCacheEntry = (cacheKey: string): UnifiedResourcesCacheE
}
const created: UnifiedResourcesCacheEntry = {
resources: [],
policyPosture: null,
hasSnapshot: false,
cachedAt: 0,
lastFetchAt: 0,
@ -757,8 +824,10 @@ const setUnifiedResourcesCache = (
entry: UnifiedResourcesCacheEntry,
resources: Resource[],
at = Date.now(),
policyPosture: ResourcePolicyPostureSummary | null = entry.policyPosture,
) => {
entry.resources = resources;
entry.policyPosture = policyPosture;
entry.hasSnapshot = true;
entry.cachedAt = at;
};
@ -829,6 +898,7 @@ const seedUnifiedResourcesCacheFromAllResources = (
entry.resources = allResourcesEntry.resources.filter((resource) =>
typeFilter.has(resolveType(resource.type)),
);
entry.policyPosture = allResourcesEntry.policyPosture;
entry.hasSnapshot = true;
entry.cachedAt = allResourcesEntry.cachedAt;
entry.lastFetchAt = allResourcesEntry.lastFetchAt;
@ -836,9 +906,10 @@ const seedUnifiedResourcesCacheFromAllResources = (
return entry;
};
async function fetchUnifiedResources(query: string): Promise<Resource[]> {
async function fetchUnifiedResources(query: string): Promise<UnifiedResourcesSnapshot> {
const normalizedQuery = normalizeUnifiedResourcesQuery(query);
const allRawResources: APIResource[] = [];
let policyPosture: ResourcePolicyPostureSummary | null = null;
let totalPages = 1;
for (let page = 1; page <= totalPages && page <= UNIFIED_RESOURCES_MAX_PAGES; page += 1) {
@ -856,10 +927,14 @@ async function fetchUnifiedResources(query: string): Promise<Resource[]> {
const payload = (await response.json()) as APIListResponse | APIResource[];
const resolved = resolveResourcesPayload(payload);
allRawResources.push(...resolved.data);
policyPosture = policyPosture ?? resolved.policyPosture;
totalPages = Math.max(totalPages, resolved.totalPages);
}
return dedupeResources(allRawResources).map((resource) => toResource(resource));
return {
resources: dedupeResources(allRawResources).map((resource) => toResource(resource)),
policyPosture,
};
}
const fetchUnifiedResourcesShared = async (
@ -878,9 +953,9 @@ const fetchUnifiedResourcesShared = async (
const request = (async () => {
const fetched = await fetchUnifiedResources(query);
const now = Date.now();
setUnifiedResourcesCache(entry, fetched, now);
setUnifiedResourcesCache(entry, fetched.resources, now, fetched.policyPosture);
entry.lastFetchAt = now;
return fetched;
return fetched.resources;
})();
entry.sharedFetch = request;
@ -938,9 +1013,13 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
orgScope(),
);
const initialResources = cacheEntry.resources;
const initialPolicyPosture = cacheEntry.policyPosture;
const hasCachedResources = cacheEntry.hasSnapshot;
const [resources, setResources] = createStore<Resource[]>(initialResources);
const [policyPosture, setPolicyPosture] = createSignal<ResourcePolicyPostureSummary | null>(
initialPolicyPosture,
);
const [loading, setLoading] = createSignal(!hasCachedResources);
const [error, setError] = createSignal<unknown>(undefined);
const wsStore = getGlobalWebSocketStore();
@ -961,6 +1040,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
return;
}
setResources(reconcile(next, { key: 'id' }));
setPolicyPosture(targetEntry.policyPosture);
};
const runRefetch = async (options?: {
@ -1149,7 +1229,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
wsResources,
allResourcesEntry.resources,
);
const projectedResources = filterCanonicalUnifiedResources(mergedWsResources, query, typeFilter);
const projectedResources = filterCanonicalUnifiedResources(
mergedWsResources,
query,
typeFilter,
);
const now = Date.now();
clearInitialHydrationTimeout();
setUnifiedResourcesCache(allResourcesEntry, mergedWsResources, now);
@ -1167,6 +1251,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
cacheEntry.lastFetchAt = now;
batch(() => {
setResources(reconcile(mergedProjectedResources, { key: 'id' }));
setPolicyPosture(cacheEntry.policyPosture);
setError(undefined);
setLoading(false);
});
@ -1233,9 +1318,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
clearInitialHydrationTimeout();
const scopedResources = cacheEntry.resources;
const scopedPolicyPosture = cacheEntry.policyPosture;
batch(() => {
setError(undefined);
setResources(reconcile(scopedResources, { key: 'id' }));
setPolicyPosture(scopedPolicyPosture);
setLoading(enabled() && !cacheEntry.hasSnapshot);
});
@ -1256,6 +1343,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
return {
resources: () => resources,
policyPosture,
refetch,
mutate,
loading,

View file

@ -138,6 +138,13 @@ export interface ResourcePolicy {
routing: ResourceRoutingPolicy;
}
export interface ResourcePolicyPostureSummary {
totalResources: number;
sensitivityCounts: Partial<Record<ResourceSensitivity, number>>;
routingCounts: Partial<Record<ResourceRoutingScope, number>>;
redactionCounts: Partial<Record<ResourceRedactionHint, number>>;
}
export const requiresGovernedResourceDisplay = (policy?: ResourcePolicy | null): boolean => {
if (!policy) return false;
return policy.routing.scope === 'local-only' || (policy.routing.redact?.length ?? 0) > 0;

View file

@ -10354,7 +10354,7 @@ func TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding(t *testing.T)
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
const want = `{"data":[],"meta":{"page":1,"limit":50,"total":0,"totalPages":0},"aggregations":{"total":0,"byType":{},"byStatus":{},"bySource":{}}}`
const want = `{"data":[],"meta":{"page":1,"limit":50,"total":0,"totalPages":0},"aggregations":{"total":0,"byType":{},"byStatus":{},"bySource":{},"policyPosture":{"totalResources":0,"sensitivityCounts":{},"routingCounts":{},"redactionCounts":{}}}}`
if got := strings.TrimSpace(rec.Body.String()); got != want {
t.Fatalf("tenant resource fallback contract = %s, want %s", got, want)
}
@ -10427,6 +10427,21 @@ func TestContract_ResourceListPolicyMetadata(t *testing.T) {
if got := resource.AISafeSummary; !strings.Contains(got, "virtual machine resource;") || !strings.Contains(got, "local-only context") {
t.Fatalf("aiSafeSummary = %q", got)
}
if resp.Aggregations.PolicyPosture == nil {
t.Fatal("expected policy posture aggregation in resource list contract")
}
if got := resp.Aggregations.PolicyPosture.TotalResources; got != 1 {
t.Fatalf("policyPosture.totalResources = %d, want 1", got)
}
if got := resp.Aggregations.PolicyPosture.SensitivityCounts[unifiedresources.ResourceSensitivityRestricted]; got != 1 {
t.Fatalf("policyPosture.sensitivityCounts[restricted] = %d, want 1", got)
}
if got := resp.Aggregations.PolicyPosture.RoutingCounts[unifiedresources.ResourceRoutingScopeLocalOnly]; got != 1 {
t.Fatalf("policyPosture.routingCounts[local-only] = %d, want 1", got)
}
if got := resp.Aggregations.PolicyPosture.RedactionCounts[unifiedresources.ResourceRedactionHostname]; got != 1 {
t.Fatalf("policyPosture.redactionCounts[hostname] = %d, want 1", got)
}
}
func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
@ -10662,10 +10677,11 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
TotalPages: 1,
},
Aggregations: unifiedresources.ResourceStats{
Total: 1,
ByType: map[unifiedresources.ResourceType]int{unifiedresources.ResourceTypeVM: 1},
ByStatus: map[unifiedresources.ResourceStatus]int{unifiedresources.StatusOnline: 1},
BySource: map[unifiedresources.DataSource]int{unifiedresources.SourceProxmox: 1},
Total: 1,
ByType: map[unifiedresources.ResourceType]int{unifiedresources.ResourceTypeVM: 1},
ByStatus: map[unifiedresources.ResourceStatus]int{unifiedresources.StatusOnline: 1},
BySource: map[unifiedresources.DataSource]int{unifiedresources.SourceProxmox: 1},
PolicyPosture: unifiedresources.EmptyResourcePolicyPostureSummary(),
},
}
@ -10738,7 +10754,7 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
}
],
"meta":{"page":1,"limit":50,"total":1,"totalPages":1},
"aggregations":{"total":1,"byType":{"vm":1},"byStatus":{"online":1},"bySource":{"proxmox":1}}
"aggregations":{"total":1,"byType":{"vm":1},"byStatus":{"online":1},"bySource":{"proxmox":1},"policyPosture":{"totalResources":0,"sensitivityCounts":{},"routingCounts":{},"redactionCounts":{}}}
}`
assertJSONSnapshot(t, got, want)

View file

@ -142,6 +142,7 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re
// match the canonical REST resource contract.
stats := registry.Stats()
stats.ByType = computeResourceContractByType(allResources)
stats.PolicyPosture = resourcePolicyPostureAggregation(allResources)
applyResourceContractTypes(paged)
@ -586,6 +587,7 @@ func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) {
allResources := registry.List()
stats := registry.Stats()
stats.ByType = computeResourceContractByType(allResources)
stats.PolicyPosture = resourcePolicyPostureAggregation(allResources)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
@ -1101,6 +1103,11 @@ func (r ResourcesResponse) NormalizeCollections() ResourcesResponse {
if r.Data == nil {
r.Data = []unified.Resource{}
}
if r.Aggregations.PolicyPosture == nil {
r.Aggregations.PolicyPosture = unified.EmptyResourcePolicyPostureSummary()
} else {
r.Aggregations.PolicyPosture = r.Aggregations.PolicyPosture.NormalizeCollections()
}
if r.Aggregations.ByType == nil {
r.Aggregations.ByType = map[unified.ResourceType]int{}
}
@ -2279,6 +2286,11 @@ func computeResourceContractByType(resources []unified.Resource) map[unified.Res
return m
}
func resourcePolicyPostureAggregation(resources []unified.Resource) *unified.ResourcePolicyPostureSummary {
canonicalResources := unified.RefreshCanonicalMetadataSlice(resources)
return unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources))
}
func buildDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
switch unified.CanonicalResourceType(resource.Type) {
case unified.ResourceTypeAgent:

View file

@ -1719,6 +1719,9 @@ func TestResourceAndStorageResponsesUseCanonicalEmptyCollections(t *testing.T) {
{name: "resources_by_type", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byType"}},
{name: "resources_by_status", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byStatus"}},
{name: "resources_by_source", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "bySource"}},
{name: "resources_policy_sensitivity", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "sensitivityCounts"}},
{name: "resources_policy_routing", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "routingCounts"}},
{name: "resources_policy_redaction", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "redactionCounts"}},
{name: "storage_summary_by_platform", raw: EmptyStorageSummaryResponse(), keys: []string{"byPlatform"}},
{name: "storage_summary_by_resource_type", raw: EmptyStorageSummaryResponse(), keys: []string{"byResourceType"}},
{name: "storage_summary_by_incident_category", raw: EmptyStorageSummaryResponse(), keys: []string{"byIncidentCategory"}},

View file

@ -606,6 +606,8 @@ func TestPolicyPostureSummaryIsOwnedByUnifiedResources(t *testing.T) {
filepath.Join(".", "policy_posture.go"): {
"type PolicyPostureSummary struct {",
"func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary",
"type ResourcePolicyPostureSummary struct {",
"func ResourcePolicyPostureContract(summary *PolicyPostureSummary) *ResourcePolicyPostureSummary",
},
filepath.Join("..", "ai", "intelligence.go"): {
"unifiedresources.PolicyPostureSummary",
@ -614,6 +616,10 @@ func TestPolicyPostureSummaryIsOwnedByUnifiedResources(t *testing.T) {
filepath.Join("..", "ai", "resource_context.go"): {
"unifiedresources.SummarizePolicyPosture(allResources)",
},
filepath.Join("..", "api", "resources.go"): {
"resourcePolicyPostureAggregation(allResources)",
"unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources))",
},
}
for name, snippets := range requiredSnippets {

View file

@ -9,6 +9,15 @@ type PolicyPostureSummary struct {
RedactionCounts map[ResourceRedactionHint]int `json:"redaction_counts,omitempty"`
}
// ResourcePolicyPostureSummary is the camelCase REST contract for policy posture
// exposed through resource aggregations.
type ResourcePolicyPostureSummary struct {
TotalResources int `json:"totalResources"`
SensitivityCounts map[ResourceSensitivity]int `json:"sensitivityCounts"`
RoutingCounts map[ResourceRoutingScope]int `json:"routingCounts"`
RedactionCounts map[ResourceRedactionHint]int `json:"redactionCounts"`
}
// SummarizePolicyPosture aggregates canonical policy posture across the given
// unified resources.
func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary {
@ -46,3 +55,73 @@ func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary {
return summary
}
// ResourcePolicyPostureContract converts the canonical policy posture summary
// into the resource API's camelCase, non-null collection contract.
func ResourcePolicyPostureContract(summary *PolicyPostureSummary) *ResourcePolicyPostureSummary {
contract := &ResourcePolicyPostureSummary{}
if summary != nil {
contract.TotalResources = summary.TotalResources
contract.SensitivityCounts = cloneResourceSensitivityCounts(summary.SensitivityCounts)
contract.RoutingCounts = cloneResourceRoutingCounts(summary.RoutingCounts)
contract.RedactionCounts = cloneResourceRedactionCounts(summary.RedactionCounts)
}
return contract.NormalizeCollections()
}
// EmptyResourcePolicyPostureSummary returns the canonical empty resource API
// policy posture contract.
func EmptyResourcePolicyPostureSummary() *ResourcePolicyPostureSummary {
return (&ResourcePolicyPostureSummary{}).NormalizeCollections()
}
// NormalizeCollections keeps resource API policy posture maps as JSON objects
// instead of nulls.
func (summary *ResourcePolicyPostureSummary) NormalizeCollections() *ResourcePolicyPostureSummary {
if summary == nil {
return EmptyResourcePolicyPostureSummary()
}
if summary.SensitivityCounts == nil {
summary.SensitivityCounts = map[ResourceSensitivity]int{}
}
if summary.RoutingCounts == nil {
summary.RoutingCounts = map[ResourceRoutingScope]int{}
}
if summary.RedactionCounts == nil {
summary.RedactionCounts = map[ResourceRedactionHint]int{}
}
return summary
}
func cloneResourceSensitivityCounts(in map[ResourceSensitivity]int) map[ResourceSensitivity]int {
if in == nil {
return nil
}
out := make(map[ResourceSensitivity]int, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneResourceRoutingCounts(in map[ResourceRoutingScope]int) map[ResourceRoutingScope]int {
if in == nil {
return nil
}
out := make(map[ResourceRoutingScope]int, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneResourceRedactionCounts(in map[ResourceRedactionHint]int) map[ResourceRedactionHint]int {
if in == nil {
return nil
}
out := make(map[ResourceRedactionHint]int, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View file

@ -78,3 +78,39 @@ func TestSummarizePolicyPosture(t *testing.T) {
t.Fatal("expected hostname redaction count")
}
}
func TestResourcePolicyPostureContractUsesCamelCaseNonNullCollections(t *testing.T) {
t.Parallel()
summary := &PolicyPostureSummary{
TotalResources: 2,
SensitivityCounts: map[ResourceSensitivity]int{
ResourceSensitivityRestricted: 1,
},
RoutingCounts: map[ResourceRoutingScope]int{
ResourceRoutingScopeLocalOnly: 1,
},
}
contract := ResourcePolicyPostureContract(summary)
if contract == nil {
t.Fatal("expected resource policy posture contract")
}
if contract.TotalResources != 2 {
t.Fatalf("total resources = %d, want 2", contract.TotalResources)
}
if got := contract.SensitivityCounts[ResourceSensitivityRestricted]; got != 1 {
t.Fatalf("restricted sensitivity count = %d, want 1", got)
}
if got := contract.RoutingCounts[ResourceRoutingScopeLocalOnly]; got != 1 {
t.Fatalf("local only routing count = %d, want 1", got)
}
if contract.RedactionCounts == nil {
t.Fatal("expected empty redaction counts map, got nil")
}
summary.SensitivityCounts[ResourceSensitivityRestricted] = 5
if got := contract.SensitivityCounts[ResourceSensitivityRestricted]; got != 1 {
t.Fatalf("contract mutated with source summary: got %d want 1", got)
}
}

View file

@ -1125,8 +1125,9 @@ type DockerSwarmInfo struct {
// ResourceStats contains aggregated stats for a set of resources.
type ResourceStats struct {
Total int `json:"total"`
ByType map[ResourceType]int `json:"byType"`
ByStatus map[ResourceStatus]int `json:"byStatus"`
BySource map[DataSource]int `json:"bySource"`
Total int `json:"total"`
ByType map[ResourceType]int `json:"byType"`
ByStatus map[ResourceStatus]int `json:"byStatus"`
BySource map[DataSource]int `json:"bySource"`
PolicyPosture *ResourcePolicyPostureSummary `json:"policyPosture,omitempty"`
}