Power BI for Drug Diversion Prevention
ETL pipeline, star-schema model, DAX measures library, Power Query recipes, dashboard design, and governance for controlled substance surveillance analytics.
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.
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.
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 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.
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).
DimDatehas one active relationship per fact table (DispenseDate → FactDispense). Additional date columns (AdminDate, WasteDate) are inactive; activate them per-measure withUSERELATIONSHIP.- 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 / 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 Measures Library
MinutesToWaste) in Power Query, not as DAX calculated columns. Use DIVIDE everywhere — never the / operator — to handle divide-by-zero without errors. Hide surrogate key columns from Report View. Thresholds are from published surveillance research: Derington et al. (late waste ≥ 60 min after administration); Knight et al. (late waste > 4 h after dispense); anomaly screening at 2 SD with 3 SD as the harder screen.
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 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"
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.
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.
Sources & Further Reading
Clinical & Regulatory Research
- ASHP Guidelines on Preventing Diversion of Controlled Substances (2022) — primary governance framework; daily override review and monthly surveillance review recommendations.
- Knight et al., AJHP 2022 — ML detection study — five-source cross-system model; 74-day earlier detection finding.
- Derington et al., Hosp Pharm 2019 — surveillance methods comparison — PPV comparison across methods; peer-comparison threshold rationale; late waste ≥ 60 min operational definition.
- DEA 21 CFR Part 1304 — Records and Reports — 2-year retention (§1304.04), biennial inventory (§1304.11), Schedule I/II separation.
Last reviewed: September 2026 · Content is educational, not legal advice.