Skip to main content

Scope Drift UX Enhancement: Implementation Plan

Date: 2026-02-25 Status: Draft v2 (addresses review findings) Depends on: Backend phases (1-3) can run in parallel with Gap 2 (remediation) since they touch different files (sections.ts vs new remediation.ts). Frontend phases (4-5) must merge AFTER Gap 2 frontend (Steps 8-10) since both modify EvidencePackViewer.tsx and FindingDetail.tsx. Effort estimate: 1-2 days Owner: TBD Target: Week 2 (by Mar 11)


Problem Description

The scope_drift evaluator rule fires correctly when an identity or workload accumulates HAS_ROLE relationships beyond its baseline count. The Finding Detail page shows a text explanation:

"Identity has 5 role(s) now vs 2 at first observation. 3 role(s) added since baseline."

This tells the user that drift occurred but does not tell the "so what" story:

  • WHAT drifted: Which specific roles were added? What are their names?
  • WHEN it drifted: At which sync did each new role appear? Was it a single bulk grant or incremental creep?
  • WHAT it now grants access to: Each new role grants permissions that APPLY_TO resources. What resources — at what sensitivity level — does the expanded scope now reach?

The evidence pack sections (authority_snapshot, temporal_context, blast_radius) contain raw data about the current state but don't present a before/after narrative. The EvidencePackViewer renders all sections identically as generic key-value tables. A security reviewer must manually navigate to Temporal Comparison, pick dates, and cross-reference roles to resources.


Architectural Analysis

Data Already Available

In the scope_drift rule's evidence_refs (stored in FindingDoc):

{
current_role_count: 5,
baseline_role_count: 2,
added_role_targets: ["sn-role-ap-write", "sn-role-ar-write", "sn-role-ledger-admin"],
baseline_version_date: "2025-11-27T00:00:00.000Z"
}

This is stored in MongoDB but never exposed through the API — the /api/v1/findings/:id endpoint does not include evidence_refs in its response.

In the evidence pack:

  • authority_snapshot.roles — current full role list with names
  • authority_snapshot.execution_paths — current resources reachable via roles
  • temporal_context.version_count and drift_event_count
  • blast_radius.resource_list with sensitivity per resource

In entity version history (/api/v1/entities/:id/versions):

  • Full version snapshots at each sync point
  • Each version includes relationships[] with HAS_ROLE entries
  • Versions have valid_at timestamps and sync_version numbers

In the entity diff API (/api/v1/entities/:id/diff?from=&to=):

  • relationships.added and relationships.removed with full objects
  • execution_paths.added and execution_paths.removed with resource names, sensitivity, via_roles

Data Gaps

  1. evidence_refs not in API response: The finding detail endpoint strips evidence_refs. The added_role_targets array is the critical missing link.
  2. No per-version role diff in evidence pack: The authority_snapshot shows current state but not the delta. There's no "scope_drift_detail" section.
  3. No role-to-resource mapping for individual added roles: Blast radius shows all reachable resources but doesn't attribute which are reachable because of the newly added roles.
  4. Version history not fetched on FindingDetail page: The page fetches only the finding and evidence pack.

Approach: Enrich the Evidence Pack

Add a new scope_drift_detail section computed during buildEvidencePackContent() when the finding type is scope_drift. This follows the platform's design principles:

  • Evidence-grade: Drift detail becomes part of the immutable, integrity-hashed evidence pack
  • No new endpoints: The UI already fetches the evidence pack; a new section appears automatically
  • Deterministic: Computed from version history already in EvidenceBuildContext
  • Temporal: Each added role attributed to the version where it first appeared

Additionally, expose evidence_refs in the finding detail API response.


UX Design

Scope Drift Detail Panel

When finding type is scope_drift, the FindingDetail page renders a dedicated panel above the generic evidence pack sections, with three sub-sections:

A. Drift Summary Header

Compact summary card:

Baseline: 2 roles (observed 2025-11-27)
Current: 5 roles (as of 2026-02-25)
Drift: +3 roles added

Visual: two stacked horizontal bars (baseline in gray, current in blue) with the delta highlighted in amber.

B. Drift Timeline

Vertical timeline showing each version where roles changed:

[dot] Sync 2 — Feb 25, 2026
+ AP Write → AP/AR Ledger (restricted)
+ AR Write → AP/AR Ledger (restricted)
+ Ledger Admin → Financial Records API (restricted)

[dot] Baseline — Nov 27, 2025
Finance Read → Financial Records API (restricted)
Invoice View → Invoice Archive (confidential)

Each added role shows:

  • Role name (resolved from entity batch lookup)
  • Arrow to the resource(s) it grants access to
  • Sensitivity badge (color-coded: restricted=red, confidential=amber, internal=gray)

Baseline roles shown as a collapsed group at the bottom.

C. Blast Radius Comparison (Before vs After)

Side-by-side comparison:

Before (baseline):

  • 2 resources reachable
  • 1 restricted, 1 confidential

After (current):

  • 3 resources reachable
  • 2 restricted, 1 confidential
  • NEW: AP/AR Ledger (restricted) — via AP Write, AR Write, Ledger Admin

New resources highlighted with a green-bordered card and delta indicator.

Integration Points

  1. FindingDetail page: Check finding.finding_type === "scope_drift" and render ScopeDriftDetail component between Explanation and Evidence Pack.
  2. EvidencePackViewer: Add scope_drift_detail to SECTION_ORDER between authority_snapshot and ownership_timeline.

Type Definitions

New Types (in src/domain/evidence-packs/types.ts)

export interface ScopeDriftRoleEntry {
role_id: string;
role_name: string;
first_seen_version: number;
first_seen_at: string;
grants_access_to: Array<{
resource_id: string;
resource_name: string;
sensitivity: string;
business_domain: string;
actions: string[];
}>;
}

export interface ScopeDriftDetailSection {
baseline_role_count: number;
current_role_count: number;
added_role_count: number;
baseline_version_date: string;
baseline_roles: Array<{ role_id: string; role_name: string }>;
added_roles: ScopeDriftRoleEntry[];
blast_radius_before: {
total_resources: number;
by_sensitivity: Record<string, number>;
};
blast_radius_after: {
total_resources: number;
by_sensitivity: Record<string, number>;
};
new_resources: Array<{
resource_id: string;
resource_name: string;
sensitivity: string;
business_domain: string;
via_roles: string[];
}>;
}

Add scope_drift_detail?: ScopeDriftDetailSection to EvidencePackContent (optional, only present for scope_drift findings).

buildScopeDriftDetail Logic

  1. Return undefined if finding type is not scope_drift.
  2. Extract added_role_targets and baseline_version_date from ctx.finding.evidence_refs.
  3. Walk ctx.versionHistory to find the earliest version where each added role appears — set first_seen_version and first_seen_at.
  4. Resolve role names from version history, not just current roleEntities.
    • ctx.roleEntities only contains entities for CURRENT HAS_ROLE targets (see build-evidence-pack.ts lines 43-72 — it reads entity.relationships which is the current snapshot).
    • If a role was added and later removed, it will NOT be in ctx.roleEntities.
    • Fix: In buildScopeDriftDetail, collect ALL role IDs from added_role_targets. For any that are NOT in ctx.roleEntities, fall back to extracting the role name from the version history itself (each version's relationships[] entries contain the target_id and often a display_name or the role node's source_id).
    • Alternative fix (preferred): Extend build-evidence-pack.ts to also load role entities for added_role_targets from ctx.finding.evidence_refs when the finding type is scope_drift. This is a small change (add to the roleIds collection before the batch fetch) and ensures the evidence pack has complete role data.
  5. Compute per-role resource access by examining ctx.entity.execution_paths and filtering by via_role_ids that include the added role.
  6. Compute baseline blast radius from the oldest version's execution_paths.
  7. Compute current blast radius from current entity's execution_paths.
  8. Identify "new resources" (in current but not baseline) and attribute to which added roles grant access.

Historical role loading (Step 2b addition to build-evidence-pack.ts):

// In build-evidence-pack.ts, after collecting roleIds from current relationships:
if (finding.finding_type === "scope_drift" && finding.evidence_refs?.added_role_targets) {
const addedTargets = finding.evidence_refs.added_role_targets as string[];
for (const id of addedTargets) {
if (!roleIds.includes(id)) roleIds.push(id);
}
}

This ensures ctx.roleEntities includes both current AND historically-added roles. If a role entity no longer exists in the DB (deleted), getEntitiesByIds will simply not return it, and the builder falls back to the role ID string.


Demo Data Requirements

Current Seed State

seed-demo-w1.ts already creates a compelling scope drift scenario:

  • Sync 1 (baseline): id-svc-finance has 2 roles: Finance Read, Invoice View
  • Sync 2 (full): id-svc-finance has 5 roles: adds AP Write, AR Write, Ledger Admin
  • The 3 added roles grant write/admin access to AP/AR Ledger (restricted) and Financial Records API (restricted)

This is already compelling: a service principal that started with read-only finance access now has write and admin access to restricted financial resources.

Enhancement: Add Intermediate Sync for 3-Point Timeline

To show incremental role creep (the more concerning real-world pattern), add a third sync between baseline and current:

  1. Sync 1 (baseline, ~90 days ago): 2 roles — already exists
  2. Sync 1.5 (intermediate, ~30 days ago): 3 roles (add AP Write only) — NEW
  3. Sync 2 (current): 5 roles (add AR Write, Ledger Admin) — already exists

This creates a 3-point timeline:

[Nov 27] Baseline: Finance Read, Invoice View
[Jan 25] +1 role: AP Write → AP/AR Ledger (restricted)
[Feb 25] +2 roles: AR Write → AP/AR Ledger (restricted)
Ledger Admin → Financial Records API (restricted)

Seed changes required:

  • New sync1_5Payload with wl-invoice-rule and id-svc-finance having 3 roles
  • Insert API call and poll between existing sync 1 and sync 2
  • Use timestamp ~30 days ago
  • Add temporal marker: "AP Write role added by change request CR-4821"

Second Scope Drift Case

The seed already includes a second candidate: wl-ascribe-summarizer goes from 1 role (SQL Admin Reader) to 2 roles (adds SQL Clinical Reader). This is a lighter case showing the feature works for workloads with direct HAS_ROLE, and the clinical data access makes it a compelling healthcare scenario.


Implementation Steps

Phase 1: Backend — Expose evidence_refs in Finding Detail API

StepFileDescriptionEffort
1asrc/api/routes/findings.tsAdd evidence_refs: finding.evidence_refs to GET /findings/:id response10 min
1bui/src/api/api-types.tsAdd evidence_refs?: Record<string, unknown> to DetailedFinding5 min

Phase 2: Backend — Add scope_drift_detail section to evidence pack

StepFileDescriptionEffort
2asrc/domain/evidence-packs/types.tsDefine ScopeDriftRoleEntry, ScopeDriftDetailSection, add optional field to EvidencePackContent20 min
2bsrc/evidence/sections.tsImplement buildScopeDriftDetail(ctx) — version history walk, role resolution, per-role resource attribution, baseline/current blast radius2-3 hr
2csrc/evidence/markdown.tsAdd markdown rendering for scope_drift_detail section30 min

Phase 3: Backend — Enrich scope_drift rule evidence_refs

Important: Do NOT add added_role_names to evidence_refs.

The finding change detection (evaluator/index.ts lines 287-304) compares evidence_refs via sorted JSON — excluding only sync_id. If display names are stored in evidence_refs, any entity rename (e.g., "Finance Read" → "Finance Reader") would trigger a "changed" finding, which triggers an evidence pack rebuild, even though the actual risk state is unchanged. This creates unnecessary churn.

Instead, role name resolution should happen only in the evidence pack builder (which already has ctx.roleEntities), not in the evaluator rule.

StepFileDescriptionEffort
3asrc/evaluator/rules/scope-drift.tsNo change to evidence_refs. Keep added_role_targets (IDs only). Role name resolution happens downstream in the evidence pack builder, not in the rule.0 min

Phase 4: Frontend — ScopeDriftDetail component

StepFileDescriptionEffort
4aui/src/components/findings/ScopeDriftDetail.tsx (NEW)Drift Summary header (baseline vs current bar), Drift Timeline (vertical timeline with role additions and resource access), Blast Radius Comparison (before/after)3-4 hr

Reuse existing components:

  • SeverityBadge pattern for sensitivity badges
  • DeltaIndicator for "+3 roles" delta display
  • DiffSection pattern from TemporalComparePage for "added" cards

Phase 5: Frontend — Integration

StepFileDescriptionEffort
5aui/src/pages/FindingDetail.tsxCheck finding_type === "scope_drift", render ScopeDriftDetail between Explanation and Evidence Pack30 min
5bui/src/components/findings/EvidencePackViewer.tsxAdd scope_drift_detail to SECTION_ORDER, add rendering branch in SectionContent30 min
5cui/src/api/api-types.tsAdd scope_drift_detail to EvidencePackContent type5 min

Phase 6: Seed Data Enhancement

StepFileDescriptionEffort
6ascripts/seed-demo-w1.tsAdd intermediate sync between sync 1 and 2 with AP Write only, temporal marker, ~30 days ago timestamp1-2 hr

Phase 7: Tests

StepFileDescriptionEffort
7atest/evidence/sections.test.tsTest buildScopeDriftDetail with version history showing role additions, verify returns undefined for non-scope_drift, verify per-role resource attribution1-2 hr
7btest/evaluator/rules/scope-drift.test.tsVerify evidence_refs contains added_role_targets (IDs only, no display names). Confirm no added_role_names key exists.15 min

Dependency Graph

Phase 1 (evidence_refs) ──── no dependencies
Phase 3 (rule enrichment) ── no dependencies
Phase 2 (evidence pack) ──── depends on types from Phase 2a
Phase 4 (UI component) ───── depends on types from Phase 2a
Phase 5 (UI integration) ─── depends on Phase 4
Phase 6 (seed data) ──────── no dependencies (parallel)
Phase 7 (tests) ───────────── depends on all above

Recommended: Phase 1 + 3 first (small, unblocks data), then Phase 2 (core logic), then Phase 4 + 5 (UI), then Phase 6 (demo), then Phase 7 (tests).


Risks

RiskMitigation
via_roles attribution: execution_paths[].via_roles contains role names (not IDs), while via_role_ids is optional. The scope drift rule tracks added_role_targets as IDs.roleEntities in evidence build context provides the ID-to-name mapping. Extended to also load historical role targets from evidence_refs (see buildScopeDriftDetail Logic).
Historical roles not in ctx.roleEntities: build-evidence-pack.ts only loads current HAS_ROLE targets. Roles that were added and later removed would be missing.Fix: extend role ID collection in build-evidence-pack.ts to include evidence_refs.added_role_targets for scope_drift findings. Falls back to role ID string if entity no longer exists.
Evidence_refs churn from display names: Putting display names in evidence_refs would trigger finding change detection on entity renames.Decision: do NOT store role names in evidence_refs. Keep added_role_targets (IDs only). Name resolution happens in evidence pack builder, not evaluator rule.
Evidence pack integrity: Adding scope_drift_detail changes the content hash.Expected — pack chaining via previous_pack_id preserves history. Section is optional so older packs remain valid.
Performance: Version history walk is bounded by existing 50-version limit.One additional batch query for historical role entities (small — typically 1-5 IDs).
Backward compatibility: New key in EvidencePackContent.Optional field. UI skips undefined sections. evidence_refs addition to API is additive.
File collision with remediation plan: Both plans touch src/evidence/sections.ts and ui/src/components/findings/EvidencePackViewer.tsx.Backend phases can run in parallel (remediation adds new file, scope drift modifies sections.ts). Frontend phases must merge sequentially — remediation first, then scope drift adds to the same components.