This page covers the Power BI layer on top of the SQL staging patterns described in Analytics Dashboard Blueprint and Tough Issues. Those pages cover SQL queries and KPI definitions; this page covers how to move that data into Power BI correctly — from raw source extracts through a production-grade star schema to a governed, HIPAA-aware dashboard.

The approach is grounded in published surveillance research. Knight et al. (AJHP 2022) consolidated five source systems and detected all 22 known diversion cases a median of 74 days faster than manual review. Derington et al. (Hosp Pharm 2019) found that EHR-derived reports have the highest positive predictive value (23.9%) but that false positives dominate all automated methods — making peer-comparison thresholds essential, not just raw flags. ASHP's 2022 Guidelines on Preventing Diversion of Controlled Substances (doi:10.1093/ajhp/zxac246) recommend monthly surveillance review and daily review of ADC override reports; this Power BI architecture is designed to support exactly that cadence.

OVERVIEW
Why Power BI for Diversion Surveillance

Manual review of ADC transaction logs and wastage records is labor-intensive and surface-level. The case for a dedicated analytics layer is well-supported:

74-Day Earlier Detection

Knight et al. (AJHP 2022) consolidated five source systems — ADC, MAR, pharmacy vault, AIMS, and HR — and detected all 22 confirmed diversion cases a median of 74 days faster than manual review. Cross-system linkage is the multiplier.

Peer Comparison Over Raw Flags

Derington et al. (Hosp Pharm 2019) found EHR-derived reports have a PPV of 23.9% — the best of any single method, but still below a useful threshold for solo use. Use peer-comparison z-scores (2 SD screen, 3 SD hard flag), not raw counts alone, to reduce false-positive burden.

ASHP-Aligned Cadence

ASHP 2022 guidelines recommend daily ADC override review and monthly surveillance review. Power BI incremental refresh, data alerts, and bookmarks let a small diversion team operationalize both cadences without rebuilding reports.

The SQL patterns in Analytics Dashboard Blueprint and Tough Issues produce the staging tables this Power BI model consumes. This page does not duplicate that SQL content — it starts where those pages end: data already landed in a staging schema, ready to be modeled.

ETL UPSTREAM
The Data Landscape

Diversion surveillance requires integrating six or more source systems, each with its own data model and extract format. The table below lists the minimum required extracts and the key fields to capture from each.

Source System What to Extract Key Fields
EMR / EHR
(MAR, orders)
Medication administration records, orders, pain scores order_id, patient_mrn (tokenized), medication, dose, admin_datetime, documented_by
ADC / Dispensing
(automated dispensing cabinets)
Dispense transactions (profiled + override), waste, returns, restocks, counts user_id, medication, qty, timestamp, patient, order_no, profiled_flag, override_flag
Anesthesia System
(AIMS)
Per-case CS sign-out, anesthesia record, post-case reconciliation provider_id, case_id, medication, qty_signed_out, qty_documented, qty_wasted
Inventory / Vault
(central pharmacy)
Movement into and within the CS vault from_location, to_location, user_id, medication, qty, timestamp
Wholesaler Invoices Incoming CS shipments invoice_no, ndc, qty, receipt_date
Time & Attendance Clock in/out for all CS-touching staff employee_id, clock_in, clock_out
HR / Access Control Role, department, hire/termination dates, ADC access grants employee_id, role, department, hire_date, termination_date, access_granted, access_revoked

Cross-System Challenges

Medication ID Mapping

The ADC, EMR, and pharmacy system each use different medication identifiers. Build a formulary crosswalk: local med ID ↔ NDC ↔ generic/brand name ↔ DEA schedule. Without this, dispense-to-MAR reconciliation is impossible.

User Identity Resolution

ADC logins and EMR logins rarely share a user ID. Map on employee ID as the anchor key. Handle name changes and dual-role staff (a nurse who also holds a pharmacy tech license). HR is the authoritative source.

Null / Partial Data Is a Signal

An ADC dispense with no matching MAR record is not a data-quality problem to discard — it is a diversion signal. Use LEFT OUTER joins so un-charted dispenses survive into the fact table with a ChartedFlag = FALSE column. Dropping NULLs silences the alarm.

Clock Skew & Timezone Normalization

ADC timestamps and EMR timestamps are often in different timezones or have NTP drift. Normalize everything to a single timezone in the Silver layer. "Dispense after documented administration" flags break — and generate false positives — if timestamps are not aligned.

ETL
ETL Architecture — Medallion Pattern
🥉
Bronze

Raw source extracts, never transformed. Append-only. This is the audit-grade source of truth — DEA and legal review will start here. Snapshot monthly for the biennial inventory countdown (see Page 4 of dashboard).

🥈
Silver

Apply formulary crosswalks, resolve user identities, normalize timestamps, deduplicate, compute derived fields (MinutesToWaste, IsOffHours, IsOnClock, ChartedFlag). This is where PHI transformation and tokenization occurs.

🥇
Gold

Star schema — fact and dimension tables ready for incremental refresh into Power BI. Aggregation-friendly. No PHI. Optimized for Import mode query performance.

Power BI (Import)

In-memory Import mode for fast DAX evaluation. Incremental refresh via RangeStart/RangeEnd parameters keeps the Gold layer's rolling window current without full reloads.

PHI Minimization at Source

Never load patient names, MRNs, or dates of birth into the Power BI dataset. Instead, apply an HMAC-keyed hash in the Silver layer to produce a PatientToken:

-- SQL Silver layer: hash the MRN, drop the original
SELECT
    CONVERT(VARCHAR(64),
        HASHBYTES('SHA2_256', CONVERT(VARCHAR, patient_mrn) + @salt),
        2) AS PatientToken,
    dispense_datetime,
    user_id,
    medication_id,
    qty_dispensed
    -- patient_mrn intentionally excluded from SELECT
FROM bronze.adc_dispense;

The PatientToken allows dispense-to-MAR reconciliation (join two fact tables on the same token) without exposing PHI. Keep the HMAC key in a secrets store — not in the Power BI dataset or M code.

DATA MODEL
Star-Schema Data Model

Fact Tables

Fact Table Grain Key Columns
FactDispense One row per ADC dispense event DispenseDateTime, UserKey, MedicationKey, LocationKey, OrderKey, PatientToken, IsOverride, OrderExistsFlag, ChartedFlag, IsOnClock, QuantityDispensed, QuantityAdministered, QuantityWasted, AdministeredDateTime
FactWaste One row per waste event WasteDateTime, WasterUserKey, WitnessUserKey, WasteReasonKey, WasteQuantity, MinutesToWaste
FactOverride One row per override dispense Subset of FactDispense with IsOverride = TRUE; kept separate for performance on override-specific visuals
FactCountDiscrepancy One row per ADC count event CountDateTime, UserKey, LocationKey, MedicationKey, ExpectedQuantity, ActualQuantity, VarianceQuantity, IsBlindCount
FactUserAccess One row per ADC/pharmacy access session AccessDateTime, UserKey, LocationKey, DurationMinutes, IsOffHours, IsAfterTermination

Dimension Tables

Dimension Notes
DimDate Role-playing dimension — used as DispenseDate, AdminDate, WasteDate, and AccessDate. Switch active relationships with USERELATIONSHIP in DAX. Disable Power BI's built-in Auto Date/Time — it creates hidden date tables that inflate model size.
DimUser Employee role, department, hire date, termination date, IsDiversionOfficer flag. Drives RLS — see Section 9.
DimMedication NDC, generic name, brand name, DEA schedule, HighRiskFlag. Built from the formulary crosswalk.
DimLocation Facility, building, unit, ADC cabinet ID, IsHighRiskArea flag.
DimOrder Order ID, ordering provider, order status. Enables MAR reconciliation joins.
DimWasteReason Small junk dimension: waste reason codes (partial dose, expired, dropped, etc.).
DimCountType Blind count vs. non-blind count — affects discrepancy analysis interpretation.

Relationship Rules

  • All relationships are 1:many (dimension → fact).
  • DimDate has one active relationship per fact table (DispenseDate → FactDispense). Additional date columns (AdminDate, WasteDate) are inactive; activate them per-measure with USERELATIONSHIP.
  • Use single cross-filter direction on all relationships. Bidirectional cross-filter causes unexpected filter leakage between fact tables.
  • Hide all surrogate key columns from Report View. Users should navigate by names and labels, not integer keys.
  • Disable Auto Date/Time globally (File → Options → Data Load → uncheck Auto date/time). It creates hidden date tables for every date column and inflates model size significantly.
POWER QUERY
Power Query / M Recipes

Compute row-level fields (lags, flags, durations) in Power Query — not as DAX calculated columns. Calculated columns in DAX are evaluated at refresh time and stored in the model, but they cannot be compressed as effectively as imported columns and they cannot use parallelism available in the engine. Do the heavy lifting in M, land clean columns in the fact tables.

1 — Incremental Refresh Filter (FactDispense)

Declare RangeStart and RangeEnd as Power BI parameters (type Date/Time, marked as query parameters in the Manage Parameters dialog). Power BI rewrites these as server-side predicates during incremental refresh so only the new partition is loaded from the database.

let
    Source = Sql.Database("sql-server", "GoldDB"),
    FactDispense_Raw = Source{[Schema="gold", Item="FactDispense"]}[Data],
    // Incremental refresh: Power BI folds these predicates to SQL WHERE
    Filtered = Table.SelectRows(
        FactDispense_Raw,
        each [DispenseDateTime] >= RangeStart
             and [DispenseDateTime] < RangeEnd
    ),
    // PHI minimization: drop any columns that should not leave SQL Server
    RemovedPHI = Table.RemoveColumns(
        Filtered,
        {"PatientFirstName", "PatientLastName", "PatientDOB"}
    )
in
    RemovedPHI

2 — Date Dimension Generator

let
    StartDate   = #date(2020, 1, 1),
    EndDate     = #date(2030, 12, 31),
    DateList    = List.Dates(StartDate, Duration.Days(EndDate - StartDate) + 1, #duration(1,0,0,0)),
    DateTable   = Table.FromList(DateList, Splitter.SplitByNothing(), {"Date"}),
    TypedDates  = Table.TransformColumnTypes(DateTable, {{"Date", type date}}),
    AddYear     = Table.AddColumn(TypedDates,  "Year",        each Date.Year([Date]),    Int64.Type),
    AddMonth    = Table.AddColumn(AddYear,     "MonthNo",     each Date.Month([Date]),   Int64.Type),
    AddMonthName= Table.AddColumn(AddMonth,    "MonthName",   each Date.ToText([Date], "MMMM"), type text),
    AddQuarter  = Table.AddColumn(AddMonthName,"Quarter",     each "Q" & Text.From(Date.QuarterOfYear([Date])), type text),
    AddWeekday  = Table.AddColumn(AddQuarter,  "DayOfWeek",   each Date.DayOfWeek([Date], Day.Monday), Int64.Type),
    AddIsWeekend= Table.AddColumn(AddWeekday,  "IsWeekend",   each Date.DayOfWeek([Date], Day.Monday) >= 5, type logical),
    AddDateKey  = Table.AddColumn(AddIsWeekend,"DateKey",     each Date.Year([Date])*10000 + Date.Month([Date])*100 + Date.Day([Date]), Int64.Type)
in
    AddDateKey

3 — User Identity Merge (ADC ↔ EMR ↔ HR)

Employee ID is the anchor key. ADC and EMR use different login IDs; HR is authoritative. Use LeftOuter joins so ADC users not yet in HR (termination lag) still appear in the model.

let
    ADC_Users  = /* query returning ADC_UserID, EmployeeID */,
    EMR_Users  = /* query returning EMR_UserID, EmployeeID */,
    HR_Users   = /* query returning EmployeeID, Name, Role, Department, HireDate, TermDate */,

    // Join ADC → HR on EmployeeID (LeftOuter keeps ADC users missing from HR)
    ADC_HR = Table.NestedJoin(
        ADC_Users, {"EmployeeID"},
        HR_Users,  {"EmployeeID"},
        "HR", JoinKind.LeftOuter
    ),
    ExpandADC_HR = Table.ExpandTableColumn(ADC_HR, "HR",
        {"Name", "Role", "Department", "HireDate", "TermDate"}),

    // Join result → EMR on EmployeeID to add EMR_UserID
    WithEMR = Table.NestedJoin(
        ExpandADC_HR, {"EmployeeID"},
        EMR_Users,    {"EmployeeID"},
        "EMR", JoinKind.LeftOuter
    ),
    ExpandEMR = Table.ExpandTableColumn(WithEMR, "EMR", {"EMR_UserID"})
in
    ExpandEMR

4 — Unpivot Multi-Witness Waste Columns

Some ADC exports produce wide-format waste records with Witness1, Witness2, Witness3, Witness4 columns. Unpivot before loading so each witness becomes a row — this enables witness-compliance analysis in DAX.

let
    Source         = /* waste table with Witness1..Witness4 columns */,
    // Identify columns to keep as-is
    AnchorColumns  = {"WasteID", "WasteDateTime", "WasterUserID",
                      "WasteQuantity", "WasteReason"},
    // Unpivot: every non-anchor column becomes an AttributeName / AttributeValue row
    Unpivoted      = Table.UnpivotOtherColumns(Source, AnchorColumns,
                         "WitnessPosition", "WitnessUserID"),
    // Remove rows where no witness was entered (nulls from empty columns)
    Cleaned        = Table.SelectRows(Unpivoted,
                         each [WitnessUserID] <> null and [WitnessUserID] <> "")
in
    Cleaned

5 — Off-Hours Classifier (Custom Function)

// Paste as a new query named "fnIsOffHours", then invoke as a custom column
(dispenseDateTime as datetime) as logical =>
let
    t         = Time.From(dispenseDateTime),
    dow       = Date.DayOfWeek(DateTime.Date(dispenseDateTime), Day.Monday),
    IsWeekend = dow >= 5,  // Saturday = 5, Sunday = 6
    IsNight   = t >= #time(19,0,0) or t < #time(7,0,0)
in
    IsWeekend or IsNight

6 — Minutes-to-Waste Column

let
    Source = /* FactWaste_Silver with DispenseDateTime and WasteDateTime */,
    AddMinutesToWaste = Table.AddColumn(
        Source,
        "MinutesToWaste",
        each Duration.TotalMinutes([WasteDateTime] - [DispenseDateTime]),
        type number
    )
in
    AddMinutesToWaste

7 — LEFT OUTER Join: Un-Charted Dispenses Survive

ADC dispenses that have no matching MAR record are a key diversion signal. The ChartedFlag column flags them; do not drop the NULLs.

let
    ADC_Dispenses  = /* Silver ADC dispense table */,
    MAR_Records    = /* Silver MAR table with DispenseID linkage */,

    // LeftOuter so every ADC dispense row survives even without a MAR match
    Joined = Table.NestedJoin(
        ADC_Dispenses, {"DispenseID"},
        MAR_Records,   {"DispenseID"},
        "MAR", JoinKind.LeftOuter
    ),
    Expand = Table.ExpandTableColumn(Joined, "MAR",
                 {"AdminDateTime", "DocumentedBy"}, {"AdminDateTime", "DocumentedBy"}),

    // False = dispensed but never charted = diversion signal
    AddChartedFlag = Table.AddColumn(Expand, "ChartedFlag",
        each [AdminDateTime] <> null, type logical)
in
    AddChartedFlag
DAX
DAX Measures Library

Waste Rate & Peer Benchmarking

-- Detects: individual waste rate vs. volume administered
User Waste Rate =
    DIVIDE(
        SUM(FactWaste[WasteQuantity]),
        SUM(FactWaste[WasteQuantity]) + SUM(FactDispense[QuantityAdministered]),
        0
    )

-- Detects: role/department peer average for z-score baseline
Peer Waste Rate Avg =
    CALCULATE(
        AVERAGEX(VALUES(DimUser[UserKey]), [User Waste Rate]),
        ALLEXCEPT(DimUser, DimUser[Role], DimUser[Department])
    )

-- Detects: spread of waste rates within peer group (denominator for z-score)
Peer Waste Rate StdDev =
    CALCULATE(
        STDEVX.P(VALUES(DimUser[UserKey]), [User Waste Rate]),
        ALLEXCEPT(DimUser, DimUser[Role], DimUser[Department])
    )

-- Detects: how many standard deviations above/below peer mean; flag at ±2 SD
User Waste Rate Z =
    DIVIDE(
        [User Waste Rate] - [Peer Waste Rate Avg],
        [Peer Waste Rate StdDev],
        0
    )

-- Detects: categorical alert label for conditional formatting and the Anomaly Watchlist
Waste Anomaly Flag =
    SWITCH(
        TRUE(),
        [User Waste Rate Z] >= 2,  "High waste - investigate",
        [User Waste Rate Z] <= -2, "Low waste - verify documentation",
        "Within expected range"
    )

-- Detects: for small peer groups where StdDev is unstable, use 75th percentile threshold
Peer P75 Waste Rate =
    CALCULATE(
        PERCENTILEX.INC(VALUES(DimUser[UserKey]), [User Waste Rate], 0.75),
        ALLEXCEPT(DimUser, DimUser[Role], DimUser[Department])
    )

Late Waste & Temporal Anomalies

-- Detects: Derington definition — waste ≥60 min after administration; Knight definition ≥240 min after dispense
Late Waste Count =
    CALCULATE(
        COUNTROWS(FactWaste),
        FactWaste[MinutesToWaste] >= 60
    )

-- Detects: dispense recorded after administration (clock skew or retroactive entry)
Dispense After Admin Count =
    CALCULATE(
        COUNTROWS(FactDispense),
        FactDispense[DispenseDateTime] > FactDispense[AdministeredDateTime]
    )

-- Detects: 30-day rolling window for trend lines
Dispenses Last 30 Days =
    CALCULATE(
        COUNTROWS(FactDispense),
        DATESINPERIOD(DimDate[Date], MAX(DimDate[Date]), -30, DAY)
    )

Override Analysis

-- Detects: ADC override pulls with no corresponding active order
Unverified Override Count =
    CALCULATE(
        COUNTROWS(FactDispense),
        FactDispense[IsOverride] = TRUE(),
        FactDispense[OrderExistsFlag] = FALSE()
    )

-- Detects: share of all dispenses that are overrides
Override Rate % =
    DIVIDE(
        CALCULATE(COUNTROWS(FactDispense), FactDispense[IsOverride] = TRUE()),
        COUNTROWS(FactDispense),
        0
    )

-- Detects: normalized override rate for benchmarking across units with different volumes
Overrides per 100 Dispenses =
    DIVIDE(
        CALCULATE(COUNTROWS(FactDispense), FactDispense[IsOverride] = TRUE()) * 100,
        COUNTROWS(FactDispense),
        0
    )

Count Discrepancies

-- Detects: proportion of counts that show a variance
Discrepancy Rate % =
    DIVIDE(
        CALCULATE(COUNTROWS(FactCountDiscrepancy),
                  FactCountDiscrepancy[VarianceQuantity] <> 0),
        COUNTROWS(FactCountDiscrepancy),
        0
    )

-- Detects: total absolute variance units — a better proxy than count of events
Absolute Variance (units) =
    SUMX(FactCountDiscrepancy, ABS(FactCountDiscrepancy[VarianceQuantity]))

-- Detects: which users are responsible for the most unresolved variance
Discrepancy User Rank =
    RANKX(
        ALL(DimUser[UserKey]),
        [Absolute Variance (units)],
        ,
        DESC,
        Dense
    )

Charting Compliance & User Behavior

-- Detects: dispenses with no corresponding MAR entry — primary reconciliation signal
Not Charted Rate % =
    DIVIDE(
        CALCULATE(COUNTROWS(FactDispense), FactDispense[ChartedFlag] = FALSE()),
        COUNTROWS(FactDispense),
        0
    )

-- Detects: off-hours ADC access — higher risk for diversion
Off-Hours Access Count =
    CALCULATE(
        COUNTROWS(FactUserAccess),
        FactUserAccess[IsOffHours] = TRUE()
    )

-- Detects: concentration of dispense volume in a single user — peer-relative outlier
User Dispense Share % =
    DIVIDE(
        COUNTROWS(FactDispense),
        CALCULATE(COUNTROWS(FactDispense), REMOVEFILTERS(DimUser)),
        0
    )

USERELATIONSHIP — Role-Playing Date Example

-- Activate the inactive AdminDate relationship to filter FactDispense by administration date
-- (The active relationship is DispenseDate; this overrides it for this measure only)
Dispenses by Admin Date =
    CALCULATE(
        COUNTROWS(FactDispense),
        USERELATIONSHIP(DimDate[Date], FactDispense[AdministeredDateTime])
    )
DASHBOARD
Dashboard Design — 6-Page Layout

Apply slicers consistently across all pages: date range, facility, unit, role, DEA schedule, medication, shift/off-hours, and flag status. Use bookmarks to save role-specific perspectives (Diversion Officer view, Unit Manager view, Executive view) — users activate bookmarks without needing to manually re-apply filters.

1Command Center (Executive)
  • KPI cards: Override Rate %, Unverified Override Count, User Waste Rate, Discrepancy Rate %, Open Anomaly Flags
  • Dispenses/day line chart with Power BI anomaly detection enabled — let the built-in model flag outlier days automatically
  • Alert table: top flagged users, sorted by z-score descending
  • Purpose: 2-minute executive briefing; no drill-down from this page
2User Behavior
  • Matrix: users × (User Waste Rate, User Waste Rate Z, Overrides per 100 Dispenses, Not Charted Rate %, Late Waste Count) with conditional formatting — red at z ≥ 2
  • Scatter chart: dispense volume (x) × waste rate (y), bubble size = override count; peers who are high-volume AND high-waste stand out immediately
  • Off-hours access bar chart by user
3Waste & Override Deep Dive
  • Waste ratio by medication (bar chart, sorted DESC)
  • Late-waste trend line (% of wastes ≥ 60 min, by month)
  • Waste-reason Pareto chart
  • Witness-compliance % tile (target: 100%)
  • Daily unverified-override list table (refreshes daily)
4Discrepancies & Inventory
  • Variance by ADC/pocket matrix (heatmap coloring by absolute variance)
  • Discrepancy rate by count type (blind vs. non-blind)
  • Count-compliance tracker (% of counts completed on schedule)
  • Biennial inventory countdown card — days until the next DEA 2-year inventory is due (21 CFR 1304.11)
5Anomaly Watchlist
  • Table: flagged users with z-scores, flag type, date first flagged, current status (Watch / Investigate / Cleared)
  • Conditional formatting: Investigate = red, Watch = amber, Cleared = green
  • Filter: status ≠ Cleared by default; diversion officer can toggle to see history
  • This page drives the monthly ASHP surveillance review meeting
6Investigation Case File (Drillthrough)
  • Drillthrough target: right-click any user on Pages 2, 3, or 5 → drill through here
  • Single-user timeline of all dispense, waste, override, and access events
  • MAR reconciliation detail: each dispense with ChartedFlag, MinutesToWaste
  • Notes text box for investigation narrative (Power BI does not persist free text — link to a SharePoint/Teams case tracker)
  • Sensitivity label applied; export to PDF/Excel disabled on this page. Drillthrough page watermark: "CONFIDENTIAL — HR/LEGAL"
OPERATIONS
Refresh & Automation
Data Layer / Metric Cadence Regulatory Driver
ADC override report Daily ASHP 2022: daily review of ADC override reports
Dispense & waste facts (incremental) Daily Operational timeliness; DEA 1304.22 same-day inventory
Count discrepancy facts Daily Internal control expectation
Peer baseline recalculation Monthly ASHP 2022: monthly surveillance review; Derington et al. monthly rolling window
Full Bronze snapshot (audit archive) Monthly DEA 21 CFR 1304.04: 2-year retention, readily retrievable
Full history reload (model rebuild) Quarterly Data quality audit; schema change management

Incremental Refresh Configuration

In Power BI Desktop: Transform data → Manage Parameters — create RangeStart and RangeEnd (type Date/Time). In Incremental refresh policy for FactDispense, set: store the last 3 years, refresh the last 7 days. This folds to server-side predicates at refresh time — only the new partition is fetched from the on-prem source via the on-premises data gateway.

Alerting & Monitoring

  • Power BI data alert: on the Command Center KPI card for Unverified Override Count, set threshold > 0 → email diversion officer. This operationalizes the ASHP daily override review requirement.
  • Power Automate on refresh failure: trigger on "Dataset refresh failed" → Teams/email notification to the analytics team. A failed refresh means the daily override review cannot happen — treat it as a control failure.
  • Gateway health: monitor on-premises data gateway heartbeat in Power BI Service Admin portal. Gateway downtime breaks all scheduled refreshes.
COMPLIANCE
Governance, HIPAA & DEA Compliance

HIPAA & Power BI Service

Microsoft's HIPAA Business Associate Agreement (BAA) is available through the Online Services Data Protection Addendum (found in the Service Trust Portal). Power BI Service is covered under that BAA. Signing the BAA is not the same as being compliant — configuration is your responsibility:

  • PHI minimization: use PatientToken (HMAC hash), never patient names, MRNs, or dates of birth in the Power BI dataset (45 CFR 164.514 — de-identification).
  • Minimum necessary (45 CFR 164.502(b)): RLS ensures users see only the data required for their role. Unit managers see their unit; diversion officers see the full picture.
  • Microsoft Purview sensitivity labels: apply "Confidential – PHI Adjacent" or your organization's equivalent label on workspaces and reports. Labels flow to exports.
  • MFA and conditional access: enforce via Entra ID (Azure AD) conditional access policies. Power BI Service alone does not enforce MFA.
  • Disable exports on sensitive pages: for Page 6 (Investigation Case File), disable export to PDF and CSV in report settings. The data should not leave the governed environment.
  • Audit logs: Power BI Service audit logs (Microsoft 365 Compliance Center) capture who viewed what and when. Retain for 90 days minimum; export monthly to your SIEM for HIPAA audit trail requirements.

Dynamic Row-Level Security (RLS)

Define roles in Power BI Desktop under Modeling → Manage Roles. Use USERPRINCIPALNAME() to make the filter dynamic — no need to manually reassign users as staff changes.

-- Role: Diversion Officer — sees all users in all units
-- DAX filter on DimUser table:
[IsDiversionOfficer] =
    LOOKUPVALUE(
        AccessControl[IsDiversionOfficer],
        AccessControl[UPN],
        USERPRINCIPALNAME()
    ) = TRUE()

-- Role: Unit Manager — sees only their assigned unit
-- DAX filter on DimLocation table:
[Unit] =
    LOOKUPVALUE(
        AccessControl[Unit],
        AccessControl[UPN],
        USERPRINCIPALNAME()
    )

-- AccessControl is a small lookup table: UPN, Unit, IsDiversionOfficer
-- Maintained in SharePoint / SQL and loaded as a dimension in the model

DEA Recordkeeping (21 CFR Part 1304)

21 CFR 1304.04 — Retention

Records must be complete, accurate, and readily retrievable. Minimum 2-year retention. Schedule I and II records kept separately from III–V. The Power BI model is a derived analytic layer, not a DEA record — the Bronze raw extracts are your authoritative source. Snapshot Bronze monthly; never delete Bronze data within the 2-year window.

21 CFR 1304.11 — Biennial Inventory

Every DEA registrant must take a physical inventory every 2 years. The biennial inventory countdown card on Dashboard Page 4 surfaces the days remaining until the next required inventory. An actual physical count must be performed — the dashboard does not substitute for the physical count, but it supports scheduling and documentation.

REFERENCES
Sources & Further Reading
Clinical & Regulatory Research
Share: LinkedIn X Facebook

Last reviewed: September 2026 · Content is educational, not legal advice.