Skip to content

Add optional row-level security guide for FinOps hubs - #2374

Open
Keerthi Math (keerthimath) wants to merge 2 commits into
microsoft:devfrom
keerthimath:keerthimath/hubs-row-level-security
Open

Keerthi Math (keerthimath) wants to merge 2 commits into
microsoft:devfrom
keerthimath:keerthimath/hubs-row-level-security

Conversation

@keerthimath

Copy link
Copy Markdown

🛠️ Description

Adds an optional, opt-in docs page for scoping FinOps hub data by subscription with Azure Data Explorer row-level security: Configure row-level security for FinOps hubs (docs-mslearn/toolkit/hubs/configure-row-level-security.md), with a TOC entry and a changelog line. Nothing in the hub template changes.

Following the review notes on #2372:

  • The first paragraph states it's an optional pattern for Data Explorer hubs and that the template doesn't change.
  • A Limitations section comes before the procedure: raw tables are only safe at raw retention 0 (restricted view access above that), Fabric eventhouses are out of scope, every service identity must be in the allow-all list, rows with an empty SubAccountId are admin-only, RLS is bound to table names and must be re-checked after every hub upgrade, shared identities, and RLS filters rows but not metadata.
  • Structure follows the other hubs pages: prerequisites, procedure (5 steps), verification, rollback.

The PowerShell command will be a separate follow-up PR.

Fixes #2372

📷 Screenshots

N/A (docs only).

📋 Checklist

🔬 How did you test this change?

  • 🤏 Lint tests
  • 🤞 PS -WhatIf / az validate
  • 👍 Manually deployed + verified
  • 💪 Unit tests
  • 🙌 Integration tests

Lint: markdownlint with docs-mslearn/.markdownlint.json, 0 errors. Manual: the procedure on a FinOps hub v14.0 (Azure Data Explorer).

Tested the procedure end to end with a temporary team and a member-only test user (removed afterwards); outcomes from the cluster's .show queries log:

Test user's access Team scope Query in Hub Result
Viewer on Hub only the team's subscription Costs() | summarize ... by SubAccountId SEM0056 ... Database='Ingestion': Access denied
Viewer on Hub and Ingestion the team's subscription same only the team's subscription (equal to the admin baseline)
Viewer on Hub and Ingestion the team's subscription database('Ingestion').Costs_raw | count 0 (raw retention 0)
Viewer on Hub and Ingestion a subscription with no data Costs() | summarize ... by SubAccountId no rows, no error

Admin row counts matched with and without set query_force_row_level_security before and after each change. The Data Explorer dashboard and an AI agent also showed only the team's data for a restricted member. Ingestion kept working with RLS enabled on the final tables. With only Viewer, a member can still list the database's principals, which is why the Limitations section says RLS filters rows, not metadata.

📦 Deploy to test?

  • Hubs + ADX (managed)
  • Hubs + Fabric (manual)
  • Hubs (manual)
  • Hubs (no data)
  • Workbooks
  • Alerts

Docs only; nothing to deploy.

🙋‍♀️ Do any of the following that apply?

  • 🚨 This is a breaking change.
  • 🤏 The change is less than 20 lines of code.

📑 Did you update docs/changelog.md?

  • ✅ Updated changelog (required for dev PRs)
  • ➡️ Will add log in a future PR (feature branch PRs only)
  • ❎ Log not needed (small/internal change)

📖 Did you update documentation?

  • ✅ Public docs in docs (required for dev)
  • ✅ Public docs in docs-mslearn (required for dev)
  • ✅ Internal dev docs in docs-wiki (required for dev)
  • ✅ Internal dev docs in src (required for dev)
  • ➡️ Will add docs in a future PR (feature branch PRs only)
  • ❎ Docs not needed (small/internal change)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Raw-table protection and security validation guidance need corrections to avoid ingestion failures and unintended data exposure.

5 open issues
What changed in this PR

Adds optional subscription-scoped row-level security guidance for FinOps hubs on Azure Data Explorer, addressing #2372 without changing hub templates.

Changes:

  • Documents prerequisites, limitations, setup, verification, and rollback.
  • Adds the guide to the navigation and changelog.
File Description
docs-mslearn/​toolkit/​hubs/​configure-row-level-security.md Adds the optional security configuration guide.
docs-mslearn/​toolkit/​changelog.md Announces the new guidance.
docs-mslearn/​TOC.yml Adds navigation to the guide.

Review effort: Balanced

Review these limitations before you start:

- **Azure Data Explorer only**: This article covers hubs that use Azure Data Explorer. Microsoft Fabric eventhouses are out of scope.
- **Raw tables**: Row-level security can't be enabled on the raw tables in the **Ingestion** database, like `Costs_raw`, because they feed update policies. They're only safe when the hub's raw retention (`dataExplorerRawRetentionInDays`) is `0`, the default, because they then keep no data. With raw retention above 0, team members could read the retained raw rows, so you also need the [restricted view access policy](/kusto/management/restricted-view-access-policy) on the raw tables.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, thanks. Fixed in d11232a: the guide now requires raw retention 0 and treats hubs that retain raw data as out of scope. Limitations states that restricted view access isn't an alternative, with a link to the update policy query limitations, and Step 1 matches that requirement.


### Step 1: Check the raw retention and blockers

Confirm that the raw tables don't keep data. Run this in the **Ingestion** database; every raw table should show a `SoftDeletePeriod` of `00:00:00`:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in d11232a: Step 1 now also requires every raw table to have a TotalRowCount of 0 (.show tables details), and explains that rows kept under an earlier retention setting are deleted asynchronously. Verified on a v14 hub: all seven raw tables return 0.


```kusto
.show table * policy row_level_security
| where Policy != 'null'

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. You were also right that the policy listing omits tables that have no policy, so filtering it can't catch a missing one. Fixed in d11232a: the check now uses .show tables details and the RowLevelSecurityPolicy column, which covers every final table and reports missing or disabled policies; it says to run it in Ingestion. Verified: it lists all ten final tables, with the two Prices tables shown as missing.

Row-level security can't be enabled on a table in some cases. For each table, run these commands in the **Ingestion** database and resolve anything they report:

```kusto
.show table Costs_final_v1_2 policy update

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d11232a: the blocker check now looks for update policies that read the final tables (.show tables details | where UpdatePolicy contains '_final_v'), notes that the hub's own policies read the raw tables, and asks readers to also check the functions in any update policies they added. On a v14 hub it returns no rows, out of 11 tables with update policies.


### Step 3: Test before you enable

Creating the function doesn't change anything yet. Before you enable the policy, check that you keep seeing all rows when the function is applied to you. Both counts must match:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, thanks. I reproduced it on a scratch table: with no policy attached, the forced count matched the plain count, and once the function was attached as a disabled policy, the forced count applied it. Fixed in d11232a: Step 3 attaches the function as a disabled policy before comparing the counts. A disabled policy affects no one, and Step 4 then enables it.

@keerthimath

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Keerthi Math (keerthimath) added a commit to keerthimath/finops-nl-agent that referenced this pull request Oct 27, 2026
set query_force_row_level_security only applies a policy that is attached to the table, so on a
table's first rollout the pre-enable comparison always matched and proved nothing. apply now attaches
the function as a disabled policy first when no policy is enabled, keeps an enabled policy enabled,
and aborts if an enabled policy uses another query. Found in review of microsoft/finops-toolkit#2374.
- Require raw retention 0 and empty raw tables (TotalRowCount check); restricted view access isn't an
  alternative because the hub's update policies read the raw tables.
- Blocker check now looks for update policies that read the final tables (.show tables details).
- Attach the function as a disabled policy before the forced-RLS comparison, so the test applies it.
- Upgrade check lists every final table, including tables without a policy, and runs in Ingestion.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review 👀 PR that is ready to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional row-level security for FinOps hubs

4 participants