diff --git a/README.md b/README.md
index 9a4147c..84a6656 100644
--- a/README.md
+++ b/README.md
@@ -11,24 +11,23 @@
+
+
-> [!IMPORTANT]
-> **OLAF v1.0.0 is an independent community Preview for evaluation and
-> development, not a production-ready security product.** Its mutating path
-> depends on Microsoft's bulk Data Access Roles `PUT`, which is officially
-> documented as **Preview** and not recommended for production use.
-> [Official endpoint status](https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-data-access-security/create-or-update-data-access-roles)
+
+
+
+
+
+
-> [!CAUTION]
-> OLAF stores principal identifiers and authorization/recovery state in control
-> tables and under `Files/security`. Sensitive modes are disabled by default and
-> require a clean DAR snapshot with an ETag. The per-run workspace isolation
-> attestation is optional — OLAF records it as `attested` or `unknown` and
-> never gates on it. Complete the external access review **before uploading a
-> real workbook**. Start with [Protecting OLAF control data](docs/control-data-security.md).
+> [!IMPORTANT]
+> **An independent community Preview for evaluation and development, not a production-ready
+> security product.** Its mutating path uses Microsoft's bulk Data Access Roles `PUT`, which
+> Microsoft documents as [Preview and not recommended for production](https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-data-access-security/create-or-update-data-access-roles).
**OLAF — OneLake Access Framework** is a plan → review → apply workflow for
Microsoft Fabric OneLake data access roles. It is one self-contained Fabric
@@ -40,6 +39,46 @@ The product names above are used only to describe interoperability. See
[Microsoft's Trademark and Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks)
and the project's [platform contract](docs/platform-contract.md).
+## What is OneLake security?
+
+[OneLake security](https://learn.microsoft.com/en-us/fabric/onelake/security/data-access-control-model)
+is Microsoft Fabric's native, data-plane access control: you define **security roles on the data
+itself** (a lakehouse's tables, folders, rows, columns) and OneLake enforces them everywhere — instead
+of re-implementing security per engine with T-SQL views, workspace boundaries, or report-side filters.
+
+- **Role-based access** — grant Read per table (`/Tables/schema/table`) or folder (`/Files/...`);
+ users with no role see nothing (**deny by default**).
+ [Create and manage roles →](https://learn.microsoft.com/en-us/fabric/onelake/security/create-manage-roles)
+- **Row-level security (RLS)** — SQL-like predicates per table per role (static values; the
+ OneLake-supported operator subset — `= <> > >= < <= IN NOT AND OR IS BLANK NULL TRUE FALSE`,
+ rule C9). A member of several roles gets the **union** of what they allow.
+ [Row-level security →](https://learn.microsoft.com/en-us/fabric/onelake/security/row-level-security)
+- **Column-level security (CLS)** — hide specific columns per role.
+ [Column-level security →](https://learn.microsoft.com/en-us/fabric/onelake/security/column-level-security)
+- **Entra-native membership** — role members are Entra users, **security groups**, or service
+ principals; membership changes happen in Entra, no sync tables to maintain.
+- **One definition, every engine** — enforced across the SQL analytics endpoint, Spark, and Direct
+ Lake semantic models (Power BI), so reports and notebooks see the same filtered data.
+- **Granular write** — separate ReadWrite roles for scoped write access (RLS/CLS don't apply there).
+
+Worth knowing: roles carrying RLS/CLS are read-only; workspace Admins/Members/Contributors bypass
+OneLake security (it governs consumers); role definitions are per lakehouse.
+
+## Why OLAF?
+
+OneLake security provides the enforcement layer. OLAF makes operating that layer **reviewable,
+repeatable, and easier to govern** when deployments grow beyond a handful of roles and tables.
+
+| What you need | What OLAF does |
+|---|---|
+| **Know what will change** | `plan` shows every role and grant that will be created, updated, or omitted from the submitted payload. |
+| **Prevent an unreviewed write** | `apply` requires a saved plan and refuses stale or drifted state. |
+| **Catch risky configuration early** | Validation blocks common RLS/CLS, predicate, casing, and multi-role exposure mistakes before deployment. |
+| **Keep the runtime simple** | One unchanged notebook serves every project; configuration and runtime parameters carry project intent. |
+| **Explain what happened** | Plan and apply are linked in the audit log with who, what, when, and the relevant configuration state. |
+
+*Full rule-by-rule detail → [docs/architecture.md](docs/architecture.md).*
+
## What OLAF does
| Need | OLAF behavior |
@@ -58,6 +97,14 @@ and [SQL endpoint enforcement guidance](https://learn.microsoft.com/en-us/fabric
## Before you start
+> [!CAUTION]
+> OLAF stores principal identifiers and authorization/recovery state in control
+> tables and under `Files/security`. Sensitive modes are disabled by default and
+> require a clean DAR snapshot with an ETag. The per-run workspace isolation
+> attestation is optional — OLAF records it as `attested` or `unknown` and
+> never gates on it. Complete the external access review **before uploading a
+> real workbook**. Start with [Protecting OLAF control data](docs/control-data-security.md).
+
- Use Microsoft Fabric Runtime 1.3 / Spark 3.5 or newer, then verify the selected
runtime and bundled package versions in your Fabric environment. Microsoft
publishes the [runtime lifecycle](https://learn.microsoft.com/en-us/fabric/data-engineering/lifecycle)
@@ -68,7 +115,7 @@ and [SQL endpoint enforcement guidance](https://learn.microsoft.com/en-us/fabric
and the [bulk DAR authorization contract](https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-data-access-security/create-or-update-data-access-roles).
- Treat the same-lakehouse layout as a trusted-administrator boundary, not
cryptographic or transactional isolation. If that threat model is unacceptable,
- do not import real principal data or run sensitive modes in v1.0.0.
+ do not import real principal data or run sensitive modes in this Preview.
## Quick start
@@ -211,6 +258,34 @@ partial or ambiguous operation. Follow
- [Testing guide](docs/testing.md)
- [Roadmap](docs/roadmap.md)
+## Roadmap
+
+Options, not promises or release dates. Each is blocked on a specific, checkable condition, and none
+may weaken the control-data boundary or turn an observed service behavior into a platform contract.
+**[docs/roadmap.md](docs/roadmap.md)** carries the detail and the official sources behind each one.
+
+- [ ] **Differential apply** — per-role writes instead of one bulk `PUT`, so an apply touches only
+ what changed. *Blocked on a stable, concurrency-safe official contract: the single-role
+ endpoints are themselves Preview, and the design needs documented precondition behavior plus a
+ recovery model for a partially completed role set.*
+- [ ] **RLS + CLS cross-grant detection at `generate`** — closing rule C5's blind spot, where a user
+ arrives through a group on one side and directly on the other. *The conservative rule stays
+ until evidence justifies otherwise; OLAF deliberately does not call Microsoft Graph.*
+- [ ] **Scheduled drift detection** — a gate an adopter schedules rather than wires themselves.
+ *Must stay read-only, separate identity drift from policy drift, and keep real principal values
+ out of CI logs.*
+- [ ] **Local authoring tool** — validate and build a config from a developer machine against a
+ real workspace, so table names, member resolution and rule violations surface while the
+ config is still being written instead of at `generate`. *Blocked on where real identifiers
+ and a Fabric token are allowed to live once they leave the Fabric boundary; validation only,
+ never a second write path.*
+- [ ] **Separate control store** — a separately secured store or independently managed keys, for
+ organizations that cannot accept v1's trusted-administrator boundary. *Not a minimal v1 change.*
+
+**Not planned:** replacing Fabric enforcement with an OLAF query layer · workspace and item RBAC ·
+calling Microsoft Graph in v1 · claiming production readiness while the required endpoint is Preview
+— [the full list and why](docs/roadmap.md#not-planned).
+
## Repository layout
```text
@@ -233,6 +308,15 @@ General questions and sanitized bug reports belong in
[GitHub issues](https://github.com/kengio/olaf/issues). See [SUPPORT.md](SUPPORT.md)
and [CONTRIBUTING.md](CONTRIBUTING.md).
+
+
+
+
+
+ The Arctic Owl keeps watch over your lake — every change is seen, reviewed,
+ and approved before it is submitted.
+
+
## License and identity
Code, documentation, and project artwork are licensed under [MIT](LICENSE), with
diff --git a/assets/brand/PROVENANCE.md b/assets/brand/PROVENANCE.md
index 4f75741..4c0feda 100644
--- a/assets/brand/PROVENANCE.md
+++ b/assets/brand/PROVENANCE.md
@@ -9,9 +9,9 @@ personal attribution or private working files.
|---|---|---|
| `olaf-logo.png` | `68f46d4054cce80b23f5c8255f5f0c2c306da116adbbe3c094ca0ec0d78efa28` | 512 × 512 px |
| `olaf-mascot.png` | `3078c87bd9d9a74030fb73df034f17380d01387a8dde29edf6dca5aa19740f9c` | 512 × 512 px |
-| `olaf-lockup-light.png` | `3bb7dc420e58bae403d267277f98ad1807cf859e48c30a01b3d1780899fdb887` | 1200 × 600 px |
-| `olaf-lockup-dark.png` | `0a8dcda65273cc73a079cc7b068a046dd9010c65ea50057f1dca42d84a4c0b33` | 1200 × 600 px |
-| `olaf-social-preview.png` | `f7cb6da4018700fccc49e59185d329b7491c0c5512bb50e7fc2a68306c07e9db` | 1200 × 630 px |
+| `olaf-lockup-light.png` | `f005a1b558ae309a05aacf59097a57022d91f86c45f146469f68db213dc29325` | 1280 × 640 px |
+| `olaf-lockup-dark.png` | `7ad09f4cf95564e35a5270050ae8dda75553dae1c8f8aa6b892679527cf6b94e` | 1280 × 640 px |
+| `olaf-social-preview.png` | `c5d1fec2a4925a5babad59920c0fd5b475778d288c6285efee45a6ac12ce0e0e` | 1280 × 640 px |
## Origin and transformations
@@ -20,9 +20,15 @@ contributors using generative-image tools and contributor-directed cleanup. No
customer material, personal likeness, private project asset, stock image, third-party
logo, Microsoft logo, or Microsoft product icon was supplied as source material.
-The social-preview image is a deterministic composition of `olaf-logo.png` centered
-on the project's Frost/Ice background. It contains no text, embedded profile, or
-personal metadata and introduces no third-party mark.
+The social-preview image places the arctic-owl shield beside the OLAF wordmark on the
+project's Frost/Ice background, with the project name and the plan → review → apply
+sequence set as text. It carries no embedded profile or personal metadata and
+introduces no third-party mark. Its predecessor was a text-free composition of
+`olaf-logo.png` alone; the text was added so a shared link says what the project is
+without the reader having to open it.
+
+The project name it sets is the same owner-selected name the lockups carry, so the
+trademark note below applies to this file too.
The lockups contain the owner-selected project name “OLAF — OneLake Access
Framework.” This referential product-name use carries the trademark risk documented
diff --git a/assets/brand/olaf-lockup-dark.png b/assets/brand/olaf-lockup-dark.png
index a61ad72..1d8c765 100644
Binary files a/assets/brand/olaf-lockup-dark.png and b/assets/brand/olaf-lockup-dark.png differ
diff --git a/assets/brand/olaf-lockup-light.png b/assets/brand/olaf-lockup-light.png
index d89d2e5..0dc7388 100644
Binary files a/assets/brand/olaf-lockup-light.png and b/assets/brand/olaf-lockup-light.png differ
diff --git a/assets/brand/olaf-social-preview.png b/assets/brand/olaf-social-preview.png
index 4cc0be1..fdd9273 100644
Binary files a/assets/brand/olaf-social-preview.png and b/assets/brand/olaf-social-preview.png differ
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f8beea9..34f7770 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -37,6 +37,39 @@ OLAF deliberately does not call Microsoft Graph. That is a project design choice
not a claim that Graph tokens are universally unavailable:
[NotebookUtils token audiences](https://learn.microsoft.com/en-us/fabric/data-engineering/notebookutils/notebookutils-credentials#get-token).
+## Local authoring tool — validate and build config against a real workspace
+
+**Status: planned; blocked on where real identifiers are allowed to live.**
+
+Today a config is authored blind. The workbook is filled in against what the author
+believes the lakehouse contains, and the first time anything checks it against reality
+is `generate` inside Fabric. A tool that runs on a developer machine, authenticates
+with Fabric credentials, and reads the live workspace could resolve table and column
+names, check member principals resolve, and surface rule violations while the config
+is still being written — a `plan` for the config itself, before a notebook is involved.
+
+What has to be settled first is not the API surface. It is that such a tool moves real
+tenant data onto a laptop:
+
+- **Credentials.** It would hold or broker a token with `OneLake.ReadWrite.All` scope
+ outside the Fabric boundary. The token, its cache, and its lifetime all become the
+ tool's responsibility, and a stolen laptop becomes a DAR-write capability. Read-only
+ scope for authoring is the obvious first constraint; whether that is sufficient to
+ build a config is not yet established.
+- **Real identifiers on disk.** Resolving members means reading real principal object
+ ids and display names. v1's whole control-data posture says do not put those where
+ they are not governed — see [Protecting OLAF control data](control-data-security.md).
+ A local cache, a shell history, an editor's undo file and a crash dump are all places
+ they would land by default.
+- **What it must never become.** An authoring tool reads and validates. It must not
+ grow a write path to the DAR collection, because that would move the reviewed
+ plan → approve → apply sequence off the audited runtime and onto a machine with no
+ audit trail. Validation only, with the deployment path unchanged.
+
+Until those are answered the honest position is that authoring stays in the workbook and
+validation stays in `generate` and `validate`, which already run every rule with zero
+writes.
+
## Scheduled drift detection
**Status: candidate automation after the read-only contract is stable.**
diff --git a/notebooks/olaf_cookbook.ipynb b/notebooks/olaf_cookbook.ipynb
index f99d1d9..865e03e 100644
--- a/notebooks/olaf_cookbook.ipynb
+++ b/notebooks/olaf_cookbook.ipynb
@@ -2,132 +2,1120 @@
"cells": [
{
"cell_type": "markdown",
- "id": "cookbook-intro",
+ "id": "title",
"metadata": {},
"source": [
- "# OLAF cookbook — safe evaluation scaffold\n",
+ "# olaf_cookbook — copy-paste examples for the `OLAF` facade\n",
"\n",
- "> **Community Preview:** OLAF — OneLake Access Framework is independent and is not affiliated with, endorsed by, sponsored by, or certified by Microsoft. The bulk DAR mutation endpoint is Preview and documented for evaluation/development, not production use.\n",
+ "> **Attach a lakehouse (or bind one with `%%configure`) before running any cell** — every\n",
+ "> action below resolves against the attached lakehouse. Without one, the first call fails\n",
+ "> with `UnsupportedOperationException — No default context`. See\n",
+ "> [fabric-import.md](../docs/fabric-import.md) for both binding paths.\n",
"\n",
- "This notebook contains unexecuted examples only. It has no stored outputs, no live verification evidence, and no permission to access a Fabric environment. Use only synthetic values such as `example.user@example.invalid`. Read [the platform contract](../docs/platform-contract.md), [control-data security](../docs/control-data-security.md), and [the runbook](../docs/runbook.md) before importing any notebook.\n"
+ "Worked, one-per-cell examples of every interactive `OLAF.(...)` call on the\n",
+ "self-contained [`olaf`](olaf.ipynb) runtime. Each cell shows the\n",
+ "exact params the action consumes, a one-line note on what it does, and an **example output** (as a\n",
+ "trailing comment) so you can see the shape before running. **You don't need this file to run the\n",
+ "framework** — `olaf.ipynb` alone runs every mode; this cookbook is just runnable examples.\n",
+ "\n",
+ "> ⚠️ **Live-Fabric demo — NOT run in CI.** These cells call the live DAR REST API and read real\n",
+ "> control tables, so they only execute inside a Fabric workspace with `olaf` imported\n",
+ "> and a lakehouse attached. The CI test + coverage harness targets `olaf.ipynb` only;\n",
+ "> this cookbook is never executed or measured. Values below (`priya@contoso.com`, `SalesReaders`,\n",
+ "> `2026-07-15`, …) are illustrative placeholders. **The first cell is a guard** — running this notebook whole (Run All / a pipeline / `notebook.run`) exits immediately without touching anything; run the cells you want **one at a time**.\n",
+ "\n",
+ "Every `OLAF.*` method returns a **Spark DataFrame** for an easy `display(...)` — a query method\n",
+ "returns its result table; an ops method returns a compact DataFrame *view* of the outcome envelope\n",
+ "(the raw dict stays at `OLAF.last_result`)."
]
},
{
"cell_type": "code",
- "id": "cookbook-guard",
"execution_count": null,
+ "id": "9540642a",
"metadata": {},
"outputs": [],
"source": [
- "# Safety guard: Run All exits before any example can execute.\n",
+ "# Safety guard: Run All stops here. Every example below reaches a live lakehouse, so\n",
+ "# they are meant to be read first and run one at a time, deliberately.\n",
"import notebookutils\n",
"\n",
"notebookutils.notebook.exit(\n",
" \"OLAF cookbook is examples-only; review and run individual cells deliberately\"\n",
- ")\n"
+ ")"
]
},
{
"cell_type": "markdown",
- "id": "cookbook-controls",
+ "id": "toc",
"metadata": {},
"source": [
- "## Before an individual evaluation cell\n",
+ "## Contents\n",
"\n",
- "Do not upload a real workbook before an external-access review. Sensitive modes, including first setup, are disabled by default. For every sensitive run, keep these independent controls: (1) the reviewed control-data boundary, (2) a per-run workspace-isolation attestation, and (3) a fresh immutable DAR snapshot with its ETag. OLAF creates/reads the sentinel before the first sensitive write and revalidates it with the snapshot before every sensitive write. Cleanup is containment, not proof of erasure or isolation; a same-lakehouse layout is not cryptographic or transactional isolation.\n"
+ "- **Load & configure** — `%run olaf` + `OLAF.configure(...)`\n",
+ "- **Deployment** — `explain` (dry preview) · `generate` · `validate` (zero-write dry-run) ·\n",
+ " `plan` · `apply` (full-replace / incremental) · `rollback`\n",
+ "- **Audit** — `show` (table / role / member) · `trace` · `grants` · `provenance` · `timeline` · `report` ·\n",
+ " compliance & live-DAR (`coverage` · `effective_access` · `who_can_access` · `drift`) ·\n",
+ " config time-travel & lineage (`table_history` · `at` · `config_diff` · `value_history`)\n",
+ "- **Maintenance** — `setup` · `health` · `status` · `diagnose_member`\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8352e9d69036",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ─────────────────────────────────────────────────────────────────────────────\n",
+ "# STOP — this is an EXAMPLES notebook, not one to run whole.\n",
+ "# The cells below call LIVE operations (generate / apply / rollback / setup) against the\n",
+ "# attached lakehouse. Running this notebook end-to-end — Run All, a Data Factory pipeline,\n",
+ "# or notebookutils.notebook.run — would execute them for real. So this guard runs FIRST and\n",
+ "# exits immediately on any whole-notebook run (the %run below never even loads).\n",
+ "#\n",
+ "# To use the cookbook: run the cells you want ONE AT A TIME, deliberately (start at the\n",
+ "# %run cell), or copy the ones you need into your own notebook.\n",
+ "# ─────────────────────────────────────────────────────────────────────────────\n",
+ "import notebookutils\n",
+ "\n",
+ "notebookutils.notebook.exit(\n",
+ " \"olaf_cookbook is examples-only — run cells one at a time, not the whole notebook\"\n",
+ ")"
]
},
{
"cell_type": "markdown",
- "id": "cookbook-load-doc",
+ "id": "1e406af3fd46",
"metadata": {},
"source": [
- "## Load the runtime\n",
+ "Load the runtime. `%run` is Fabric's import: it defines every function/class + the `OLAF`\n",
+ "facade in this notebook's namespace. The ▶️ Run cell is guarded, so `%run` dispatches\n",
+ "NOTHING — `OLAF` is ready, exactly like `from olaf import OLAF`. (The pipeline path never\n",
+ "`%run`s; it passes `mode`.)\n",
"\n",
- "Run the next cell by itself only after reviewing the exact release commit and attaching an authorized non-production lakehouse. The runtime notebook remains the source of truth for the API.\n"
+ "> A `%run` cell must hold **nothing else** — no comments, no code. Fabric rejects the cell\n",
+ "> with `MagicUsageError: %run cannot run with other code or magic commands.` That is why this\n",
+ "> explanation is a markdown cell rather than a comment above the magic."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "load",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%run olaf"
]
},
{
"cell_type": "code",
- "id": "cookbook-load",
"execution_count": null,
+ "id": "configure",
"metadata": {},
"outputs": [],
"source": [
- "%run olaf\n"
+ "# Optional: set base params shared by all three OLAF namespaces, so you don't repeat them on\n",
+ "# every call. Anything the parameters cell accepts works here — env, tenant_id, and the\n",
+ "# control-table names if they differ from the olaf.* defaults.\n",
+ "#\n",
+ "# When:\n",
+ "# once at the top of a session, only if your env / tenant / table names differ from the dev\n",
+ "# defaults. Chainable; skip it entirely to run against the defaults.\n",
+ "# Expect:\n",
+ "# returns a DataFrame of every parameter the next run will use, each row marked\n",
+ "# set / default / per-call; every later OLAF.() then uses these base params.\n",
+ "OLAF.configure(\n",
+ " env=\"qa\",\n",
+ " tenant_id=\"00000000-0000-0000-0000-000000000000\", # \"\" = auto-resolve from the runtime context\n",
+ " config_table=\"ml_gold.security_config\", # override any control-table name —\n",
+ " log_table=\"ml_gold.security_log\", # they don't have to use the olaf.* defaults\n",
+ ")\n",
+ "\n",
+ "# Just changing the env? One-liner: OLAF.configure(env=\"dev\")"
]
},
{
"cell_type": "markdown",
- "id": "cookbook-read-doc",
+ "id": "sec-deployment",
"metadata": {},
"source": [
- "## Read and validation examples\n",
+ "## Deployment\n",
"\n",
- "These calls express OLAF's intended read/validation behavior. They can still perform external reads and therefore require an authorized target. No example output is embedded because this repository does not claim an exact-release live run.\n"
+ "The `generate → plan → apply → rollback` chain. `generate` freezes the config into the mapping\n",
+ "lock-file; `plan` diffs it against the live DAR; `apply` pushes it; `rollback` restores a prior\n",
+ "config version. All return a compact DataFrame view of the outcome (`OLAF.last_result` = raw dict)."
]
},
{
"cell_type": "code",
- "id": "cookbook-read",
"execution_count": null,
+ "id": "dep-explain",
"metadata": {},
"outputs": [],
"source": [
- "OLAF.show_params()\n",
- "OLAF.health()\n",
+ "# Preview the roles/scopes/predicates a config WOULD produce, BEFORE generate ever runs — a dry\n",
+ "# projection over generate's own resolution chain. No mapping/log write; it DOES read onelake_security_member —\n",
+ "# safe to call against a config you haven't committed to yet.\n",
+ "#\n",
+ "# When:\n",
+ "# reviewing a config edit before generate/plan/apply — \"what would this build?\"\n",
+ "# Expect:\n",
+ "# one row per role × scope grant (members = RESOLVED display names, ';'-joined — a glob: pattern shows its expansion); an\n",
+ "# empty/all-inactive config → an empty but typed frame.\n",
+ "OLAF.explain()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (one row per role × scope grant; nothing written):\n",
+ "# +----------------+----------------------+------------+---------------+-----------------+---------------------+\n",
+ "# | role_name | scope_path | permission | rls_condition | visible_columns | members |\n",
+ "# +----------------+----------------------+------------+---------------+-----------------+---------------------+\n",
+ "# | SalesReaders | /Tables/sales/orders | Read | null | null | sg-sales |\n",
+ "# | FinanceReaders | /Tables/fin/ledger | Read | region='APAC' | amount;region | priya@contoso.com |\n",
+ "# +----------------+----------------------+------------+---------------+-----------------+---------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-generate",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Build the mapping lock-file from the short config: resolve include/exclude, validate every\n",
+ "# rule, freeze the grants + write a versioned review CSV. rebuild=False is the normal path.\n",
+ "#\n",
+ "# When:\n",
+ "# after every config edit — always before plan/apply. rebuild=False is idempotent, so\n",
+ "# re-running an unchanged config is a no-op (status \"skipped\").\n",
+ "# Expect:\n",
+ "# status \"success\" with the grants/roles/warnings counts (or \"skipped\" if unchanged),\n",
+ "# plus a new versioned CSV directly under mapping_history_dir (default Files/security/mapping-history).\n",
+ "OLAF.generate(rebuild=False)\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (OLAF._view of the generate envelope):\n",
+ "# +----------+---------+---------+--------------------------+--------+-------+----------+-------------------------------------------------------------------------------------------------+\n",
+ "# | mode | status | changed | message | grants | roles | warnings | csv |\n",
+ "# +----------+---------+---------+--------------------------+--------+-------+----------+-------------------------------------------------------------------------------------------------+\n",
+ "# | generate | success | true | 12 grants across 3 roles | 12 | 3 | 1 | Files/security/mapping-history/onelake_security_mapping_20260715-080000_v7_a1b2c3d4e5f60718.csv |\n",
+ "# +----------+---------+---------+--------------------------+--------+-------+----------+-------------------------------------------------------------------------------------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-plan",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Diff the desired state (the mapping lock-file) against the live DAR — read-only, and it is\n",
+ "# what unlocks apply.\n",
+ "#\n",
+ "# When:\n",
+ "# after generate, before apply — required (apply stays blocked until a matching plan exists\n",
+ "# with no since-plan drift).\n",
+ "# Expect:\n",
+ "# one row per change: create / update / omit / no_change. changed=false means live already\n",
+ "# matches config, so apply is skipped.\n",
+ "OLAF.plan()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (one row per changed role):\n",
+ "# +------+---------+---------+-------------+----------------+--------+\n",
+ "# | mode | status | changed | message | role | action |\n",
+ "# +------+---------+---------+-------------+----------------+--------+\n",
+ "# | plan | success | true | 3 change(s) | FinanceReaders | update |\n",
+ "# | plan | success | true | 3 change(s) | SalesReaders | create |\n",
+ "# | plan | success | true | 3 change(s) | TempAuditors | omit |\n",
+ "# +------+---------+---------+-------------+----------------+--------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-apply-inc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Push the planned roles to the live DAR. keep_unmanaged=True = INCREMENTAL upsert: managed roles\n",
+ "# are created/updated and NOTHING is dropped from the payload — hand-added Default* roles are\n",
+ "# carried through untouched.\n",
+ "#\n",
+ "# When:\n",
+ "# the deliberate opt-in — you want live roles outside config left alone. NOT the default.\n",
+ "# Expect:\n",
+ "# push_status (the bulk PUT's HTTP status, NOT a count) beside roles_written (the real count of\n",
+ "# roles the PUT body carried); roles absent from config are listed under\n",
+ "# drift_omission_candidates but are still carried in the submitted payload.\n",
+ "OLAF.apply(keep_unmanaged=True)\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (OLAF._view of the apply envelope):\n",
+ "# +-------+---------+---------+---------------------+-------------+---------------+----------------+---------------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "# | mode | status | changed | message | push_status | roles_written | keep_unmanaged | request | backup_path | omitted_role_candidates | drift_omission_candidates | post_state_review_required |\n",
+ "# +-------+---------+---------+---------------------+-------------+---------------+----------------+---------------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "# | apply | success | true | apply (incremental) | 200 | 3 | true | incremental_payload | Files/security/role-backups/onelake_..._replace.json | [] | [\"TempAuditors\"] | true |\n",
+ "# +-------+---------+---------+---------------------+-------------+---------------+----------------+---------------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "#\n",
+ "# push_status is HTTP; roles_written is the count. Incremental PUTs live ∪ desired, so the 3 here is\n",
+ "# the 2 managed roles PLUS the kept TempAuditors — the whole body the PUT wrote."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-apply-full",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# The DEFAULT apply submits config as the whole truth: every live role NOT in the desired set is\n",
+ "# OMITTED from the payload, including hand-added Default* roles (the omitted list prints first).\n",
+ "# Omission is a REQUEST shape, not a confirmed outcome — the Preview bulk endpoint does not\n",
+ "# document deletion-by-omission, so review the post-state before concluding a role is gone.\n",
+ "#\n",
+ "# When:\n",
+ "# routine deployment, right after you have reviewed the plan.\n",
+ "# Expect:\n",
+ "# the omission-candidate list printed first (roles_written = the roles the PUT body carried,\n",
+ "# omitted_role_candidates = the roles dropped by omission from it, and\n",
+ "# post_state_review_required = true because the request alone settles nothing).\n",
+ "OLAF.apply()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +-------+---------+---------+-----------------+-------------+---------------+----------------+----------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "# | mode | status | changed | message | push_status | roles_written | keep_unmanaged | request | backup_path | omitted_role_candidates | drift_omission_candidates | post_state_review_required |\n",
+ "# +-------+---------+---------+-----------------+-------------+---------------+----------------+----------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "# | apply | success | true | apply (replace) | 200 | 3 | false | config_payload | Files/security/role-backups/onelake_..._replace.json | [\"TempAuditors\"] | [\"TempAuditors\"] | true |\n",
+ "# +-------+---------+---------+-----------------+-------------+---------------+----------------+----------------+------------------------------------------------------+-------------------------+---------------------------+----------------------------+\n",
+ "#\n",
+ "# If the PUT FAILS, OLAF.apply() still returns a frame — run_mode is the non-raising engine, so the\n",
+ "# view is the 1-row apply summary carrying status \"error\" (or \"blocked\" when a first-attempt 412\n",
+ "# refused the conditional PUT); the raw envelope stays at OLAF.last_result. Only the pipeline\n",
+ "# entrypoint run_and_exit raises on a failed outcome. The record is in\n",
+ "# onelake_security_log: one `failed` row per planned role (intended action vs PRESENT/ABSENT in a\n",
+ "# re-read of live) plus a `push` summary row carrying the intended payload and backup_path. Nothing\n",
+ "# is auto-restored: a PUT that timed out may have succeeded, so restoring is a deliberate act."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-rollback",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Restore the config table to a prior Delta version, then re-run generate → plan → apply. A\n",
+ "# blank version = the immediately previous one; a value pins an exact one. reason is required.\n",
+ "#\n",
+ "# When:\n",
+ "# after a bad config edit you need to undo.\n",
+ "# Expect:\n",
+ "# config restored to the target version, the full pipeline re-run, and two durable audit rows\n",
+ "# (action \"rollback\", status \"prepared\" then \"restored\") whose message is a JSON record\n",
+ "# carrying from_version, to_version, reason, source_config_hash and target_config_hash.\n",
+ "OLAF.rollback(rollback_to_version=\"41\", rollback_reason=\"bad RLS predicate on Orders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (1-row status summary):\n",
+ "# +----------+---------+---------+-----------------------------------------------------+\n",
+ "# | mode | status | changed | message |\n",
+ "# +----------+---------+---------+-----------------------------------------------------+\n",
+ "# | rollback | success | true | rollback to config v41: bad RLS predicate on Orders |\n",
+ "# +----------+---------+---------+-----------------------------------------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dep-validate",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Dry-run the IDENTICAL validation pipeline generate runs (every rule in the docs/architecture.md\n",
+ "# rule catalog, the No-Graph member gate, the lakehouse target guard) with ZERO writes -- no\n",
+ "# mapping, no CSV, no log row (not even a 'rejected' one on a blocked run, unlike generate). Safe\n",
+ "# to run against a live deployment: it never touches the control tables.\n",
+ "#\n",
+ "# When:\n",
+ "# previewing a config edit's validation outcome before committing to generate (which writes).\n",
+ "# Expect:\n",
+ "# status \"success\" with the grant/role/warning summary on a clean config; on an invalid config\n",
+ "# a blocked view carrying the SAME collect-all error set generate would reject with (never\n",
+ "# raises — the raw envelope, with counts + every warning, stays at OLAF.last_result).\n",
"OLAF.validate()\n",
- "OLAF.explain()\n"
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (OLAF._view of the validate envelope — the 1-row status summary; the raw\n",
+ "# envelope with the grant/role counts + every warning stays at OLAF.last_result):\n",
+ "# +----------+---------+---------+---------------------------------------------------------------------------+\n",
+ "# | mode | status | changed | message |\n",
+ "# +----------+---------+---------+---------------------------------------------------------------------------+\n",
+ "# | validate | success | false | validate: 12 grant(s) across 3 role(s), 1 warning(s) — dry-run, no writes |\n",
+ "# +----------+---------+---------+---------------------------------------------------------------------------+"
]
},
{
"cell_type": "markdown",
- "id": "cookbook-sensitive",
+ "id": "sec-audit",
+ "metadata": {},
+ "source": [
+ "## Audit\n",
+ "\n",
+ "Read-only queries over the live DAR + the `onelake_security_log` trail — no SQL. `show`/`trace`\n",
+ "route through `run_mode` (they need the live target); `grants` / `provenance` / `timeline` /\n",
+ "`report` read the log directly. All return a Spark DataFrame."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-show-table",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Pivot the LIVE DAR by TABLE: who can reach sales.orders, each grant enriched from the log\n",
+ "# (first_applied/first_granted_by · last_applied/last_granted_by · config_version) and flagged\n",
+ "# framework vs out-of-band (a Fabric-UI edit).\n",
+ "#\n",
+ "# When:\n",
+ "# answering \"who can access this table?\" — audit / compliance / incident review, any time (read-only).\n",
+ "# Expect:\n",
+ "# one row per (role, member) reaching the table, with the enrichment + an out_of_band flag.\n",
+ "OLAF.show(by=\"table\", subject=\"sales.orders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (one row per role × scope × member; member = objectId, abbreviated here).\n",
+ "# by=table LEADS with scope_path — every axis returns the same eleven columns, led by its own key:\n",
+ "# +----------------------+--------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | scope_path | role_name | member | member_name | permission | first_applied | first_granted_by | last_applied | last_granted_by | config_version | provenance |\n",
+ "# +----------------------+--------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | /Tables/sales/orders | SalesReaders | 8f4e2a…c1 | priya@contoso.com | Read | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 | framework |\n",
+ "# +----------------------+--------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-show-role",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Pivot the LIVE DAR by ROLE: every table/folder scope + member that SalesReaders grants.\n",
+ "#\n",
+ "# When:\n",
+ "# reviewing one role — \"what does SalesReaders actually grant?\"\n",
+ "# Expect:\n",
+ "# one row per (scope, member) the role covers, with the same log enrichment as by=table.\n",
+ "OLAF.show(by=\"role\", subject=\"SalesReaders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (same eleven columns, led by role_name; one row per scope × member of the role):\n",
+ "# +--------------+-----------------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | role_name | scope_path | member | member_name | permission | first_applied | first_granted_by | last_applied | last_granted_by | config_version | provenance |\n",
+ "# +--------------+-----------------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | SalesReaders | /Tables/sales/orders | 8f4e2a…c1 | priya@contoso.com | Read | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 | framework |\n",
+ "# | SalesReaders | /Tables/sales/returns | 8f4e2a…c1 | priya@contoso.com | Read | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 | framework |\n",
+ "# +--------------+-----------------------+-----------+-------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-show-member",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Pivot the LIVE DAR by MEMBER: every role + scope priya@contoso.com reaches\n",
+ "# (accepts an objectId or a name / glob).\n",
+ "#\n",
+ "# When:\n",
+ "# an access review for one person — \"what can they see?\"\n",
+ "# Expect:\n",
+ "# one row per (role, scope) the member reaches, enriched + out_of_band-flagged.\n",
+ "OLAF.show(by=\"member\", subject=\"priya@contoso.com\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (same eleven columns; by=member LEADS with member + member_name):\n",
+ "# +-----------+-------------------+----------------+----------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | member | member_name | role_name | scope_path | permission | first_applied | first_granted_by | last_applied | last_granted_by | config_version | provenance |\n",
+ "# +-----------+-------------------+----------------+----------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+\n",
+ "# | 8f4e2a…c1 | priya@contoso.com | SalesReaders | /Tables/sales/orders | Read | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 | framework |\n",
+ "# | 8f4e2a…c1 | priya@contoso.com | FinanceReaders | /Tables/fin/ledger | Read | 2026-07-16 | ci@contoso | 2026-08-24 | keng@contoso | 42 | framework |\n",
+ "# +-----------+-------------------+----------------+----------------------+------------+---------------+------------------+--------------+-----------------+----------------+------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-trace",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Operational snapshot (the trace mode / Audit.report): the deployed generation, LIVE role +\n",
+ "# grant counts, staleness, and — when the live client resolves — the out-of-band grant count.\n",
+ "# Every count describes the CURRENT state except established_ever, which is cumulative across\n",
+ "# every env and config version and is named so it cannot be mistaken for today.\n",
+ "#\n",
+ "# When:\n",
+ "# a quick \"is the deployment healthy / in sync?\" check, any time (read-only).\n",
+ "# Expect:\n",
+ "# a 1-row snapshot: generation, live role/grant counts, is_stale, out_of_band count.\n",
+ "OLAF.trace()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (1-row snapshot):\n",
+ "# +-------+---------+-----------------+------------------+------------------+----------+-------------+\n",
+ "# | mode | status | live_role_count | live_grant_count | established_ever | is_stale | out_of_band |\n",
+ "# +-------+---------+-----------------+------------------+------------------+----------+-------------+\n",
+ "# | trace | success | 3 | 7 | 19 | false | 1 |\n",
+ "# +-------+---------+-----------------+------------------+------------------+----------+-------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-grants",
"metadata": {},
+ "outputs": [],
"source": [
- "## Sensitive operations — documentation only\n",
+ "# Established grants read from the LOG (no live DAR call) — one row per (role, scope, member)\n",
+ "# with provenance. Optional role / scope / member narrow the listing.\n",
+ "#\n",
+ "# When:\n",
+ "# \"what has the framework granted, and when?\" — from the audit trail, offline.\n",
+ "# Expect:\n",
+ "# one row per established (role, scope, member), each with BOTH ends of its deploy range:\n",
+ "# first_applied/first_granted_by, last_applied/last_granted_by, and config_version.\n",
+ "OLAF.grants(role=\"SalesReaders\")\n",
"\n",
- "The following sequence is intentionally non-executable. Enable a sensitive operation only after the control gates above pass:\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+\n",
+ "# | role_name | scope_path | member_id | member_name | first_applied | first_granted_by | last_applied | last_granted_by | config_version |\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+\n",
+ "# | SalesReaders | /Tables/sales/orders | 8f4e2a…c1 | priya@contoso.com | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 |\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-since",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# One established grant's provenance for a (role, scope[, member]) — the first row of grants(),\n",
+ "# as a 1-row DataFrame (or an empty frame when no such grant exists).\n",
+ "#\n",
+ "# Reports BOTH ends, and deliberately no single `since`. The log records what OLAF did; it\n",
+ "# cannot see a role deleted straight from the Fabric UI, so an apply, an out-of-band deletion\n",
+ "# and a re-apply are indistinguishable here from one unbroken grant. Read first_applied as\n",
+ "# \"first deployed on\" and last_applied as \"last re-asserted on\" — a wide gap between them is a\n",
+ "# cue to check the live DAR, not evidence either way.\n",
+ "#\n",
+ "# When:\n",
+ "# an access review — \"when did this access start, and has anyone touched it since?\"\n",
+ "# Expect:\n",
+ "# a 1-row frame with both ends and the principal who pushed each (or an empty frame if none).\n",
+ "OLAF.provenance(role=\"SalesReaders\", scope=\"/Tables/sales/orders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (1 row):\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+\n",
+ "# | role_name | scope_path | member_id | member_name | first_applied | first_granted_by | last_applied | last_granted_by | config_version |\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+\n",
+ "# | SalesReaders | /Tables/sales/orders | 8f4e2a…c1 | priya@contoso.com | 2026-07-15 | ci@contoso | 2026-08-24 | keng@contoso | 42 |\n",
+ "# +--------------+----------------------+-----------+-------------------+---------------+------------------+--------------+-----------------+----------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-timeline",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Every logged config generation as one row: first_seen / last_seen / run count per\n",
+ "# (config_version, config_hash), ordered by version — the lifetime of each generation.\n",
+ "#\n",
+ "# When:\n",
+ "# reviewing how the config has evolved over time — its change history.\n",
+ "# Expect:\n",
+ "# one row per generation: config_version, config_hash, first_seen, last_seen, runs.\n",
+ "OLAF.timeline()\n",
"\n",
- "```python\n",
- "# OLAF.setup()\n",
- "# OLAF.load_config(\"config\", \"\", \"config\")\n",
- "# OLAF.generate()\n",
- "# OLAF.plan()\n",
- "# inspect the exact request, re-attest isolation, capture snapshot + ETag, create/read sentinel\n",
- "# runtime revalidates the sentinel and snapshot before every sensitive write\n",
- "# OLAF.apply(...)\n",
- "# re-read and classify the observed post-state\n",
- "```\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +----------------+-------------+---------------------+---------------------+------+\n",
+ "# | config_version | config_hash | first_seen | last_seen | runs |\n",
+ "# +----------------+-------------+---------------------+---------------------+------+\n",
+ "# | 41 | 3b9c… | 2026-07-10T09:12:03 | 2026-07-14T18:40:55 | 6 |\n",
+ "# | 42 | a17f… | 2026-07-15T08:01:22 | 2026-07-16T11:29:07 | 4 |\n",
+ "# +----------------+-------------+---------------------+---------------------+------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-report",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# The one-call operational snapshot behind trace, as a 1-row DataFrame. Nested values (the\n",
+ "# generation / last-run dicts) are stringified; the counts and is_stale are scalar.\n",
+ "#\n",
+ "# When:\n",
+ "# same as trace, when you want the full dict fields in one row.\n",
+ "# Expect:\n",
+ "# a 1-row wide frame: current_generation, last_generate, last_apply, live counts,\n",
+ "# established_ever, is_stale, out_of_band.\n",
+ "OLAF.report()\n",
"\n",
- "`keep_unmanaged` describes OLAF's request construction. Microsoft's Preview bulk endpoint does not document atomic full-set replacement or deletion-by-omission. A backup is a recovery input, not an exact-restore guarantee. `reset()` and `cleanup()` are sensitive containment operations and are also disabled by default.\n"
+ "#\n",
+ "# → Spark DataFrame (1 row; wide — nested dicts shown abbreviated):\n",
+ "# +---------------------------+------------------+----------------+-----------------+------------------+------------------+----------+-------------+\n",
+ "# | current_generation | last_generate | last_apply | live_role_count | live_grant_count | established_ever | is_stale | out_of_band |\n",
+ "# +---------------------------+------------------+----------------+-----------------+------------------+------------------+----------+-------------+\n",
+ "# | {'config_version': 42, …} | {'mode': 'gene…} | {'mode': 'ap…} | 3 | 7 | 19 | false | 1 |\n",
+ "# +---------------------------+------------------+----------------+-----------------+------------------+------------------+----------+-------------+"
]
},
{
"cell_type": "markdown",
- "id": "cookbook-sources",
+ "id": "sec-audit-compliance",
"metadata": {},
"source": [
- "## Official references\n",
+ "### Compliance & live-DAR utilities\n",
"\n",
- "- [Bulk DAR endpoint](https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-data-access-security/create-or-update-data-access-roles)\n",
- "- [OneLake access model](https://learn.microsoft.com/en-us/fabric/onelake/security/data-access-control-model)\n",
- "- [Table, column, and row security](https://learn.microsoft.com/en-us/fabric/onelake/security/table-column-row-security)\n",
- "- [Canonical RLS syntax](https://learn.microsoft.com/en-us/fabric/onelake/security/row-level-security-syntax)\n",
- "- [NotebookUtils credentials](https://learn.microsoft.com/en-us/fabric/data-engineering/notebookutils/notebookutils-credentials#get-token)\n",
+ "Read-only queries that pivot the LIVE DAR directly (all but `coverage` need a live client) —\n",
+ "finding gaps, computing net access, and comparing desired vs. live without touching `plan`/`apply`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-coverage",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Protected vs unprotected table surface — the compliance gap finder. The table universe is every\n",
+ "# REAL table in the lakehouse catalog, not just the ones named in the mapping, so a table nobody\n",
+ "# configured at all still gets a row (protected=false) instead of being silently skipped.\n",
+ "#\n",
+ "# When:\n",
+ "# \"what tables have NO security applied at all?\" — a compliance sweep, any time (no live\n",
+ "# client needed).\n",
+ "# Expect:\n",
+ "# one row per catalog table; protected=false + roles_count=0 for anything unconfigured.\n",
+ "OLAF.coverage()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (one row per table in the catalog):\n",
+ "# +-----------------------+-----------+-------------+---------+---------+\n",
+ "# | table | protected | roles_count | has_rls | has_cls |\n",
+ "# +-----------------------+-----------+-------------+---------+---------+\n",
+ "# | sales.orders | true | 1 | true | false |\n",
+ "# | fin.ledger | true | 1 | true | true |\n",
+ "# | sales.staging_scratch | false | 0 | false | false |\n",
+ "# +-----------------------+-----------+-------------+---------+---------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-effective-access",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Net EFFECTIVE access to one table for one member — most-permissive-wins across every reaching\n",
+ "# role, plus a synthesized 'effective' union row. engine= is REQUIRED (spark | direct_lake |\n",
+ "# sql_endpoint) and is echoed as a column: on spark/direct_lake a reaching role with no RLS/CLS on\n",
+ "# the table nullifies every other role's restriction, mirroring rule C8; on sql_endpoint CLS is the\n",
+ "# INTERSECTION of the explicit allow-lists instead, so an unrestricted role does NOT erase another\n",
+ "# role's column restriction (RLS is still nullified on every engine). This models the DAR rules —\n",
+ "# it is not proof of endpoint identity mode or of enforcement for a given request.\n",
+ "# Needs a live client (Fabric only).\n",
+ "# member accepts a name/UPN (resolved case-insensitively against onelake_security_member, the\n",
+ "# same No-Graph table generate resolves from) or an objectId (GUID) passed straight through; a\n",
+ "# name absent from the member table raises UsageError naming the No-Graph limitation. The optional\n",
+ "# member_type (\"Group\"/\"User\"/\"ServicePrincipal\"/\"ManagedIdentity\") scopes resolution to the member\n",
+ "# table's logical PK (member_type, lower(member_name)) — needed only when a Group and a User share a\n",
+ "# display name, which is otherwise a hard UsageError naming the ambiguity instead of a silent pick.\n",
+ "#\n",
+ "# When:\n",
+ "# \"what can this person actually see on this table, once every role they're in is combined?\"\n",
+ "# Expect:\n",
+ "# one detail row per reaching role, plus one effective=true union row; no reaching role → an\n",
+ "# empty but typed frame.\n",
+ "OLAF.effective_access(member=\"priya@contoso.com\", table=\"sales.orders\", engine=\"spark\")\n",
+ "# member is a name/UPN preloaded in onelake_security_member — a GUID objectId also works unchanged\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (one row per reaching role, plus the union):\n",
+ "# +----------------+---------------+-----------------+-----------------------------+-----------+--------+\n",
+ "# | role_name | rls_condition | visible_columns | granting_role | effective | engine |\n",
+ "# +----------------+---------------+-----------------+-----------------------------+-----------+--------+\n",
+ "# | SalesReaders | region='APAC' | null | null | false | spark |\n",
+ "# | FinanceReaders | null | null | null | false | spark |\n",
+ "# | null | null | null | FinanceReaders;SalesReaders | true | spark |\n",
+ "# +----------------+---------------+-----------------+-----------------------------+-----------+--------+\n",
+ "# (FinanceReaders carries no RLS on this table, so the union's rls_condition is null too —\n",
+ "# \"unrestricted nullifies filter\".)\n",
+ "\n",
+ "# Same name, two principal types (a Group AND a User called \"finance-team\") — say which one:\n",
+ "OLAF.effective_access(\n",
+ " member=\"finance-team\", table=\"sales.orders\", member_type=\"Group\", engine=\"spark\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-who-can-access",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# The REVERSE of effective_access — every member who can reach this table, one row per\n",
+ "# (member, role) pair (a member reachable via two roles gets two rows). Needs a live client.\n",
+ "#\n",
+ "# When:\n",
+ "# \"who can see this table, and through which role?\" — an access review by table.\n",
+ "# Expect:\n",
+ "# one row per (member, role); rls_cls_summary is a one-cell digest ('rows: ...' / 'cols: ...' /\n",
+ "# 'unrestricted').\n",
+ "OLAF.who_can_access(table=\"sales.orders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +---------------------+-----------+----------------+------------+---------------------+\n",
+ "# | member_name | member_id | via_role | permission | rls_cls_summary |\n",
+ "# +---------------------+-----------+----------------+------------+---------------------+\n",
+ "# | priya@contoso.com | 8f4e2a…c1 | SalesReaders | Read | rows: region='APAC' |\n",
+ "# | sg-finance-leads | c9d1e0…7f | FinanceReaders | Read | unrestricted |\n",
+ "# +---------------------+-----------+----------------+------------+---------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-drift",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Full desired-vs-live DAR comparison, CATEGORIZED and READ-ONLY: framework (matches provenance) /\n",
+ "# out_of_band (no provenance) / policy (provenanced, but the deployed permission / RLS predicate /\n",
+ "# CLS allow-list differs from what the mapping declares — out_of_band WINS when a grant qualifies\n",
+ "# for both) / missing (desired but absent live). member_id + member_name are\n",
+ "# APPENDED at the end of the frame (out_of_band()'s convention); member_name is resolved id->name\n",
+ "# from the member cache table (same lookup out_of_band/who_can_access use) and falls back to the id\n",
+ "# itself when the cache has no row — which is exactly what the id/name PAIR lets you detect. A third\n",
+ "# rendering exists: an id the cache gives MORE THAN ONE distinct name surfaces as\n",
+ "# \"\", distinct from a real name AND from the bare-id fallback.\n",
+ "# A pure\n",
+ "# comparison view — it never records a plan and never gates apply (plan() stays the one\n",
+ "# apply-gating recorder). Needs a live client.\n",
+ "#\n",
+ "# When:\n",
+ "# a periodic \"is live DAR exactly what config says?\" check, independent of plan/apply.\n",
+ "# Expect:\n",
+ "# one row per grant (or per missing desired grant), tagged with its category + a short detail.\n",
+ "OLAF.drift()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +----------------+----------------------+-------------+-----------------------------------------------+-----------+---------------------+\n",
+ "# | role_name | scope_path | category | detail | member_id | member_name |\n",
+ "# +----------------+----------------------+-------------+-----------------------------------------------+-----------+---------------------+\n",
+ "# | SalesReaders | /Tables/sales/orders | framework | live grant matches framework provenance (...) | 8f4e2a…c1 | priya@contoso.com |\n",
+ "# | FinanceReaders | /Tables/fin/ledger | out_of_band | live grant has no framework provenance (...) | c9d1e0…7f | sg-finance-leads |\n",
+ "# | TempAuditors | /Tables/sales/orders | missing | desired grant absent from live DAR (...) | a2b3c4…5d | sg-temp-auditors |\n",
+ "# +----------------+----------------------+-------------+-----------------------------------------------+-----------+---------------------+"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "sec-audit-timetravel",
+ "metadata": {},
+ "source": [
+ "### Config time-travel & lineage\n",
+ "\n",
+ "Delta version-history utilities over the control tables — `table_history`/`at` work on any of\n",
+ "config/mapping/log; `config_diff`/`value_history` are config-specific, diffing the authored rows\n",
+ "across versions."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-table-history",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Delta DESCRIBE HISTORY of a control table, made readable. `table` is one of config/mapping/log\n",
+ "# (mapped to the actual configured table name).\n",
+ "#\n",
+ "# When:\n",
+ "# \"when did the config table last change, and who/what wrote it?\" — a Delta-native history,\n",
+ "# independent of onelake_security_log.\n",
+ "# Expect:\n",
+ "# one row per Delta commit, newest first (DESCRIBE HISTORY's own order).\n",
+ "OLAF.table_history(\"config\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame:\n",
+ "# +---------+---------------------------+-------------------+-----------+------+\n",
+ "# | version | timestamp | user | operation | rows |\n",
+ "# +---------+---------------------------+-------------------+-----------+------+\n",
+ "# | 42 | 2026-07-15T08:00:00+00:00 | alice@contoso.com | UPDATE | 14 |\n",
+ "# | 41 | 2026-07-10T09:00:00+00:00 | alice@contoso.com | UPDATE | 13 |\n",
+ "# +---------+---------------------------+-------------------+-----------+------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-at",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Snapshot of ANY control table (config/mapping/log) at a Delta version or date — config_at\n",
+ "# generalized to every control table. Give exactly one of version or date.\n",
+ "#\n",
+ "# When:\n",
+ "# \"what did the mapping lock-file look like right after the v41 rollback?\" — any control\n",
+ "# table, any point in time.\n",
+ "# Expect:\n",
+ "# the resolved table's own schema, as of that version/date.\n",
+ "OLAF.at(\"mapping\", version=7)\n",
"\n",
- "OLAF deliberately does not call Microsoft Graph. That is a framework design choice, not a claim that Graph tokens are universally unavailable.\n"
+ "#\n",
+ "# → Spark DataFrame (onelake_security_mapping's own schema, as of version 7 — abbreviated here):\n",
+ "# +--------------+----------------------+------------+-----+\n",
+ "# | role_name | scope_path | permission | ... |\n",
+ "# +--------------+----------------------+------------+-----+\n",
+ "# | SalesReaders | /Tables/sales/orders | Read | ... |\n",
+ "# +--------------+----------------------+------------+-----+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-config-diff",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Role/scope/member changes between two config Delta versions — added / removed / changed rows,\n",
+ "# diffed in Python after two time-travel reads (config_at(v1), config_at(v2)).\n",
+ "#\n",
+ "# When:\n",
+ "# \"what actually changed between config v41 and v42?\" — reviewing an edit after the fact.\n",
+ "# Expect:\n",
+ "# one row per added/removed role×scope key, or one row per changed field (a role with 3\n",
+ "# changed fields → 3 'changed' rows).\n",
+ "OLAF.config_diff(41, 42)\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (scope_key is the config's own include/exclude-column key, '|'-joined):\n",
+ "# +-------------+--------------+-------------------+---------------+-------+---------------+\n",
+ "# | change_type | role_name | scope_key | field | old | new |\n",
+ "# +-------------+--------------+-------------------+---------------+-------+---------------+\n",
+ "# | changed | SalesReaders | sales.orders||| | rls_condition | null | region='APAC' |\n",
+ "# | added | TempAuditors | sales.orders||| | null | null | null |\n",
+ "# +-------------+--------------+-------------------+---------------+-------+---------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aud-value-history",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# How ONE role/scope's config VALUE evolved across every config Delta version — walks every\n",
+ "# version, keeping only the rows where the subject is present, flagging `changed` on its first\n",
+ "# appearance and on any later version where a tracked field differs from the last one it appeared in.\n",
+ "#\n",
+ "# When:\n",
+ "# \"when did SalesReaders' RLS predicate actually change, across every version?\" — a full\n",
+ "# lineage for one role/scope, not just a two-version diff (that's config_diff).\n",
+ "# Expect:\n",
+ "# one row per version where the subject is present; changed=true on first appearance.\n",
+ "OLAF.value_history(subject=\"SalesReaders\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (20 columns total — every config_diff field column, abbreviated here):\n",
+ "# +----------------+--------------+-----------------+------------+---------------+-----+---------+------------------+\n",
+ "# | config_version | role_name | scope_key | permission | rls_condition | ... | changed | window_truncated |\n",
+ "# +----------------+--------------+-----------------+------------+---------------+-----+---------+------------------+\n",
+ "# | 41 | SalesReaders | sales.orders||| | Read | null | ... | true | false |\n",
+ "# | 42 | SalesReaders | sales.orders||| | Read | region='APAC' | ... | true | false |\n",
+ "# +----------------+--------------+-----------------+------------+---------------+-----+---------+------------------+"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "sec-maintenance",
+ "metadata": {},
+ "source": [
+ "## Maintenance\n",
+ "\n",
+ "Control-table lifecycle. `setup` is the only maintenance action on `OLAF` today."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "mnt-setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create (or additively migrate) the four control tables — config / mapping / log / member.\n",
+ "# Idempotent: safe to re-run; it reconciles schema drift without data loss.\n",
+ "#\n",
+ "# setup REQUIRES lakehouse_name, and it is an assertion, not a target: setup always writes to the\n",
+ "# ATTACHED lakehouse (its DDL uses two-part `olaf.…` names), so naming it here is how you get told\n",
+ "# you are attached to the wrong one — instead of finding the control tables in another workspace\n",
+ "# later. Case-insensitive; an attachment from another workspace is refused on the ids too.\n",
+ "#\n",
+ "# When:\n",
+ "# the first thing you run in a new lakehouse, and again after a framework upgrade.\n",
+ "# Expect:\n",
+ "# the four tables created/migrated + a 1-row status summary; a `blocked` row naming BOTH the\n",
+ "# declared and the attached lakehouse if they disagree.\n",
+ "OLAF.configure(lakehouse_name=\"LH_Gold\") # the lakehouse this notebook is attached to\n",
+ "OLAF.setup()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (1-row status summary):\n",
+ "# +-------+---------+---------+-----------------------------------+\n",
+ "# | mode | status | changed | message |\n",
+ "# +-------+---------+---------+-----------------------------------+\n",
+ "# | setup | success | true | created/migrated 4 control tables |\n",
+ "# +-------+---------+---------+-----------------------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "mnt-health",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# One-call doctor: 9 independent checks (control tables present, table location, mapping\n",
+ "# staleness, DAR reachability, control-data exposure, identity preflight, runtime prerequisites,\n",
+ "# last-apply age, out-of-band grants). One failing check never aborts the rest — health() always\n",
+ "# returns all 9 rows.\n",
+ "#\n",
+ "# When:\n",
+ "# \"is everything healthy?\" — a quick doctor pass, any time (never raises).\n",
+ "# Expect:\n",
+ "# always 9 rows, each pass/warn/fail with a human-readable detail.\n",
+ "OLAF.health()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (always 9 rows):\n",
+ "# +-----------------------+--------+----------------------------------------------------------------------+\n",
+ "# | check | status | detail |\n",
+ "# +-----------------------+--------+----------------------------------------------------------------------+\n",
+ "# | control_tables | pass | all 4 control tables present with the expected schema |\n",
+ "# | table_location | pass | control tables are in the attached lakehouse (LH_Gold) |\n",
+ "# | mapping_staleness | pass | mapping matches the active config (version 42) |\n",
+ "# | dar_reachable | pass | bounded DAR read succeeded with a collection ETag |\n",
+ "# | control_data_exposure | pass | JSON facts show a safe DAR snapshot and workspace_isolation=attested |\n",
+ "# | identity_preflight | pass | Fabric token acquired for the ambient identity (alice@...) |\n",
+ "# | runtime_prerequisites | pass | observed Spark meets the baseline; verify the Fabric Runtime label |\n",
+ "# | last_apply_age | pass | last apply was 2 day(s) ago |\n",
+ "# | out_of_band | warn | 1 out-of-band grant(s) with no framework provenance |\n",
+ "# +-----------------------+--------+----------------------------------------------------------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "mnt-status",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# One-call at-a-glance deployment snapshot, built purely from the log + mapping (no live client\n",
+ "# needed, unlike health()'s DAR-dependent checks).\n",
+ "#\n",
+ "# When:\n",
+ "# \"what's deployed right now, in one row?\" — a lightweight dashboard check.\n",
+ "# Expect:\n",
+ "# a 1-row frame: role/member counts, last generate/apply timestamps, the newest successful\n",
+ "# deployment and its mode, the live config version, and whether an unapplied change is pending.\n",
+ "OLAF.status()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (1 row):\n",
+ "# +---------+-----------+---------------------------+---------------------------+---------------------------+----------------------+---------------------+----------------+\n",
+ "# | n_roles | n_members | last_generate | last_apply | last_deployment | last_deployment_mode | live_config_version | pending_change |\n",
+ "# +---------+-----------+---------------------------+---------------------------+---------------------------+----------------------+---------------------+----------------+\n",
+ "# | 3 | 5 | 2026-07-15T08:00:00+00:00 | 2026-07-15T08:05:00+00:00 | 2026-07-15T08:05:00+00:00 | apply | 42 | false |\n",
+ "# +---------+-----------+---------------------------+---------------------------+---------------------------+----------------------+---------------------+----------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "mnt-diagnose-member",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Why can't `member` see data? Walks the same chain a human troubleshooter would, IN ORDER. Once a\n",
+ "# prerequisite step (member_in_table / id_resolved / in_mapping) fails, every later step is\n",
+ "# short-circuited with \"skipped — prerequisite failed\" instead of reporting a misleading guess.\n",
+ "#\n",
+ "# When:\n",
+ "# \"why doesn't priya see the data they should?\" — a single, ordered root-cause walk.\n",
+ "# Expect:\n",
+ "# always 5 rows, in order: member_in_table → id_resolved → in_mapping → live_in_dar →\n",
+ "# apply_in_sync.\n",
+ "OLAF.diagnose_member(\"priya@contoso.com\")\n",
+ "# An objectId works too — GUID pass-through, the same one effective_access gives (step 1 reports\n",
+ "# the pass-through instead of a name lookup), so an id copied out of who_can_access() is accepted:\n",
+ "OLAF.diagnose_member(\"8f4e2a00-0000-0000-0000-0000000000c1\")\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame (always 5 rows, in order):\n",
+ "# +-----------------+------+--------------------------------------------------------------+\n",
+ "# | step | ok | detail |\n",
+ "# +-----------------+------+--------------------------------------------------------------+\n",
+ "# | member_in_table | true | found in olaf.onelake_security_member (type User) |\n",
+ "# | id_resolved | true | resolved to objectId 8f4e2a…c1 |\n",
+ "# | in_mapping | true | in role(s): SalesReaders |\n",
+ "# | live_in_dar | true | live in role(s): SalesReaders |\n",
+ "# | apply_in_sync | true | mapping is in sync with the last apply (2026-07-15T08:05:00) |\n",
+ "# +-----------------+------+--------------------------------------------------------------+"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c1d92bb00146",
+ "metadata": {},
+ "source": [
+ "## 🔥 Destructive — read before running either cell below\n",
+ "\n",
+ "> ### These two are destructive. There is no dry run, no review gate, and no undo prompt.\n",
+ "> The moment the cell finishes, it is done.\n",
+ "\n",
+ "| | `OLAF.reset()` | `OLAF.cleanup()` |\n",
+ "|---|---|---|\n",
+ "| **does** | submits an **empty DAR payload**, omitting **every** live role — OLAF's, other people's, and `Default*` | deletes all four control tables (**including the whole audit log**) + **every** mapping-history and role-backup file |\n",
+ "| **leaves** | the control tables — config, mapping, member, log | the live roles, now with no trail explaining them |\n",
+ "| **way back** | the pre-request backup it writes first — a recovery *input*, not a guaranteed exact restore, and there is no public replay method: recovery is a break-glass incident (RUNBOOK §3c) | **none. It deletes the backups too.** |\n",
+ "\n",
+ "**`reset()` submits a request; it does not report an outcome.** The roles it returns are\n",
+ "`prior_live_role_candidate`s — observed before submission and omitted from the request. The Preview\n",
+ "bulk endpoint does not document deletion-by-omission, so OLAF will not tell you the roles are gone,\n",
+ "that OneLake security is now deny-by-default, or that nobody can read the data. `post_state_review_required`\n",
+ "comes back **true**: re-read the post-state in the target engine and access mode before drawing any\n",
+ "access conclusion. Note the platform's `DefaultReader` is among the omitted roles and OLAF does\n",
+ "**not** recreate platform-managed roles.\n",
+ "\n",
+ "⚠️ **Do not assume a uniform outcome across principals** — workspace roles, default roles, engine/access\n",
+ "mode and shortcut behavior all remain relevant, and OLAF claims neither universal enforcement nor a\n",
+ "universal privileged-role bypass. Verify the post-state in each engine/access mode you actually care about.\n",
+ "\n",
+ "⚠️ **`cleanup()` cannot log what it did** — it drops the log table. The returned frame and the\n",
+ "printed lines are the only record that run will ever produce.\n",
+ "\n",
+ "Neither is a mode: a pipeline passing `mode=\"reset\"` is refused by name. They exist only here."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b65ae578acd3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 🔥 DESTRUCTIVE containment request. SUBMITS AN EMPTY DAR PAYLOAD, omitting every data access\n",
+ "# role on the attached lakehouse — OLAF's own, anyone else's, and Default*. OLAF does not\n",
+ "# recreate platform-managed/default roles.\n",
+ "#\n",
+ "# What comes back are CANDIDATES, not confirmed deletions: roles observed before submission and\n",
+ "# omitted from the request. The Preview contract does not establish deletion-by-omission,\n",
+ "# no-OneLake-security, or universal-reader outcomes, so post_state_review_required is true —\n",
+ "# review the post-state in the target engine/access mode before concluding anything about access.\n",
+ "#\n",
+ "# Writes a role backup FIRST; if that capture fails the reset aborts before submitting anything.\n",
+ "# The backup is a recovery INPUT, not a guarantee of exact platform-state restoration (RUNBOOK 3c).\n",
+ "# Control tables are untouched, so generate -> plan -> apply can submit the config's payload again.\n",
+ "#\n",
+ "# When:\n",
+ "# handing a lakehouse back, or starting its security over from nothing.\n",
+ "# Review the post-state afterwards in the engine/access mode you care about — OLAF claims neither\n",
+ "# universal enforcement nor a universal privileged-role bypass.\n",
+ "OLAF.reset()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame — one row per prior-live role candidate, with the request label, the backup\n",
+ "# artifact, and the review flag:\n",
+ "# +---------------------------+---------------+----------------------------------------------------+----------------------------+\n",
+ "# | prior_live_role_candidate | request | backup_path | post_state_review_required |\n",
+ "# +---------------------------+---------------+----------------------------------------------------+----------------------------+\n",
+ "# | DefaultReader | empty_payload | Files/security/role-backups/onelake_..._reset.json | true |\n",
+ "# | SalesReaders | empty_payload | Files/security/role-backups/onelake_..._reset.json | true |\n",
+ "# +---------------------------+---------------+----------------------------------------------------+----------------------------+"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4b54a5d44b98",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 🔥 THE ONE WITH NO WAY BACK. Drops all four control tables — the authored config, the mapping,\n",
+ "# the member table and the ENTIRE AUDIT HISTORY — and deletes every mapping-history CSV and every\n",
+ "# pre-apply role backup. Those backups are the recovery for a bad apply and for reset(); after\n",
+ "# this there is nothing left to restore from.\n",
+ "#\n",
+ "# It does NOT touch the live roles. Any still deployed are listed in the result, now with no audit\n",
+ "# trail behind them — run reset() FIRST if you want them out of the DAR payload, because\n",
+ "# afterwards its backup is gone too.\n",
+ "#\n",
+ "# The frame also carries what cleanup could NOT do: items it failed to remove, the incident\n",
+ "# sentinel it deliberately preserves, and an explicit exposure-not-remediated row. Containment\n",
+ "# is not proof of erasure, and the manifest says so rather than reading as an all-clear.\n",
+ "#\n",
+ "# It cannot log what it did (it drops the log). The frame below and the printed lines are the ONLY\n",
+ "# record. Copy them somewhere before closing the notebook.\n",
+ "#\n",
+ "# When:\n",
+ "# first-time deploy into an environment a trial run left dirty, so setup() starts from nothing.\n",
+ "OLAF.cleanup()\n",
+ "\n",
+ "#\n",
+ "# → Spark DataFrame — the manifest, and the only record this run produces:\n",
+ "# +-----------------------------+------------------------------------------------------------------+\n",
+ "# | kind | name |\n",
+ "# +-----------------------------+------------------------------------------------------------------+\n",
+ "# | dropped table | olaf.onelake_security_config |\n",
+ "# | dropped table | olaf.onelake_security_log |\n",
+ "# | deleted file | Files/security/role-backups/onelake_..._replace.json |\n",
+ "# | LIVE ROLE LEFT BEHIND | SalesReaders |\n",
+ "# | INCIDENT SENTINEL PRESERVED | Files/security/.olaf-sensitive-write.sentinel |\n",
+ "# | EXPOSURE NOT REMEDIATED | exposure_remediated=false; cleanup cannot retract prior reads, … |\n",
+ "# +-----------------------------+------------------------------------------------------------------+"
]
}
],
"metadata": {
- "kernelspec": {
- "display_name": "Synapse PySpark",
+ "a365ComputeOptions": null,
+ "kernel_info": {
+ "jupyter_kernel_name": null,
"name": "synapse_pyspark"
},
"language_info": {
"name": "python"
- }
+ },
+ "sessionKeepAliveTimeout": 0
},
"nbformat": 4,
"nbformat_minor": 5
diff --git a/notebooks/olaf_master_workflow.ipynb b/notebooks/olaf_master_workflow.ipynb
index 644bfcd..4e34ef7 100644
--- a/notebooks/olaf_master_workflow.ipynb
+++ b/notebooks/olaf_master_workflow.ipynb
@@ -2,8 +2,8 @@
"cells": [
{
"cell_type": "code",
- "id": "workflow-binding",
"execution_count": null,
+ "id": "0393df6c34a4",
"metadata": {},
"outputs": [],
"source": [
@@ -12,38 +12,169 @@
" \"defaultLakehouse\": {\n",
" \"name\": {\n",
" \"parameterName\": \"lakehouse_name\",\n",
- " \"defaultValue\": \"SyntheticEvaluationLakehouse\"\n",
+ " \"defaultValue\": \"YourLakehouse\"\n",
" }\n",
" }\n",
- "}\n"
+ "}"
]
},
{
"cell_type": "markdown",
- "id": "workflow-intro",
+ "id": "0963a30fbcb6",
"metadata": {},
"source": [
- "# OLAF master workflow — controlled evaluation scaffold\n",
+ "> 🔗 **Session binding** — the `%%configure` cell above pins this run's default lakehouse **by\n",
+ "> name, before the session starts**. On a pipeline run the `lakehouse_name` **base parameter**\n",
+ "> feeds it — the same parameter the Parameters cell below receives, so one pipeline parameter\n",
+ "> drives both the session binding and `setup`'s assertion. Interactive users can instead simply\n",
+ "> attach a lakehouse in the portal; `defaultValue` is then what running the cell would bind, so\n",
+ "> keep it naming the lakehouse `lakehouse_name` asserts. Fail-visible either way: if no binding\n",
+ "> lands, the run blocks on OLAF's `no lakehouse attached` guard (and `setup` re-asserts the\n",
+ "> name) — it never writes anywhere unintended."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0da628dda910",
+ "metadata": {},
+ "source": [
+ "# OneLake Security — Master Notebook\n",
+ "\n",
+ "Runs `olaf` through a full deployment, **one stage per cell**, with each stage's result deciding the\n",
+ "next. This is exactly what a Fabric / ADF pipeline does with one `notebook.run` activity per stage —\n",
+ "same sequence, same branching — just without the pipeline.\n",
+ "\n",
+ "> ⚠️ **Before running:** attach the lakehouse you are securing. Every stage resolves its control\n",
+ "> tables and its target through whatever lakehouse is *attached*, so that attachment is the setting\n",
+ "> that matters. (The `lakehouse_name` parameter is read only by `setup`, which asserts the attached\n",
+ "> lakehouse is the one you named — a typo there is caught only when `run_setup = True`.) OLAF also refuses a lakehouse attached from a\n",
+ "> different workspace — run this from the workspace that owns it.\n",
+ "\n",
+ "**`keep_unmanaged = False` is deliberate.** `apply` submits the config as the whole truth, so any\n",
+ "role the config does not declare is omitted from the payload — including one somebody added by hand in the portal, and including\n",
+ "`Default*` — a leftover `DefaultReader` reads every path and bypasses RLS/CLS, so leaving it in place\n",
+ "would quietly undo the whole config. That\n",
+ "that omission is the point: **the config is the only way to change access.** Editing roles outside the\n",
+ "framework does not survive the next run, by design — so do not flip this to `True` to make a run\n",
+ "feel safer.\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## The flow\n",
+ "\n",
+ "```\n",
+ " setup ─▶ load_config ─▶ validate ─▶ generate ─▶ plan ─┬─▶ no changes ──────────────────▶ done\n",
+ " ┈┈┈┈┈┈ ┈┈┈┈┈┈┈┈┈┈┈ ┈┈┈┈┈┈┈┈ ┈┈┈┈┈┈┈┈ ┈┈┈┈ │\n",
+ " opt-in workbook→ no writes lock-file diff └─▶ changes ─▶ gate ─▶ apply ─▶ verify\n",
+ " control tables ┈┈┈┈ ┈┈┈┈┈\n",
+ " AUTO_APPROVE the only\n",
+ " live write\n",
+ "```\n",
"\n",
- "> **Community Preview:** OLAF — OneLake Access Framework is independent and is not affiliated with, endorsed by, sponsored by, or certified by Microsoft. Its bulk DAR mutation dependency is Preview and is documented for evaluation/development, not production use.\n",
+ "**Run it as often as you like.** Every stage is safe to repeat: `setup` is idempotent, `load_config`\n",
+ "is a full REPLACE from the workbook, `generate` skips when the config hash has not moved, and `plan`\n",
+ "finds no drift when the live state already matches. Re-running is the intended loop, not an\n",
+ "exceptional path.\n",
"\n",
- "This notebook is deliberately inert by default. It contains no live results and grants no permission to access or mutate a Fabric environment. The session binding above is not an isolation or authorization boundary. Read [the platform contract](../docs/platform-contract.md), [control-data security](../docs/control-data-security.md), and [the runbook](../docs/runbook.md) before adapting it.\n"
+ "> ⚠️ **Edited the workbook? Set `load_config = True` for that run.** It is `False` by default, and a\n",
+ "> run with it off never reads the workbook: the config table is unchanged, so `generate` skips, `plan`\n",
+ "> reports no drift, and the whole notebook goes green **without your edit ever reaching the vault**.\n",
+ "> The failure mode of forgetting is a success verdict, which is why it is called out here and not\n",
+ "> only at the stage.\n",
+ "\n",
+ "| # | Stage | Writes | What it does |\n",
+ "|---|---|---|---|\n",
+ "| 1 | `setup` | control tables | Creates them if missing, and migrates columns after a framework upgrade. Off by default — set `run_setup = True` to run it. |\n",
+ "| 1b | `load_config` | config + member | Loads the authored workbook into the two author-owned control tables. A full REPLACE, so a row deleted in the workbook is deleted here. Writes no log row — this is authoring, not deployment. Off by default. |\n",
+ "| 2 | `validate` | **nothing** | Every rule `generate` runs, against the config. Collects *all* failures. |\n",
+ "| 3 | `generate` | mapping + CSV | Commits the config into the lock-file `plan` and `apply` read. |\n",
+ "| 4 | `plan` | log row | Diffs mapping against live. **No live change.** Sets `changed`. |\n",
+ "| 5 | `apply` | **live roles** | The only live write. Refuses unless a successful `plan` exists for this same **config_hash**, and the live state still matches it. |\n",
+ "| 6 | verify | nothing | Reads the live state back through the `OLAF` facade. |\n",
+ "\n",
+ "**`validate` runs before `generate`, not after.** It is a dry-run of the very validation\n",
+ "`generate` performs, with zero writes — so it previews the commit, and previewing *after*\n",
+ "committing would be the wrong way round.\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## Two ways to call OLAF, and the choice is load-bearing\n",
+ "\n",
+ "This notebook uses **both**, on a rule:\n",
+ "\n",
+ "| | used for | on a blocked stage |\n",
+ "|---|---|---|\n",
+ "| `notebookutils.notebook.run` | every stage whose refusal must **stop the run** — setup, validate, generate, plan, apply, rollback | **raises**, so the cell fails and the run stops |\n",
+ "| the `OLAF` facade | `configure` and `load_config`, which have no mode of their own · and the read-only tail (`show`, `trace`) where there is no refusal to gate on | returns a frame, **never raises on outcome** |\n",
+ "\n",
+ "> The split is **not** \"is it a mode?\" — `show` and `trace` are modes (all eight are: setup, generate,\n",
+ "> validate, plan, apply, rollback, show, trace). The split is whether a refusal has to halt the run.\n",
+ "> A read stage has nothing to halt, so the frame is the more useful shape.\n",
+ "\n",
+ "**Do not \"simplify\" this into an all-facade notebook.** The facade is the interactive surface and is\n",
+ "pinned by tests as never raising on outcome (`OLAF.generate()` on a config the member gate refuses\n",
+ "hands back a `blocked` frame). Drive the stages that way and a refused config prints a frame and the\n",
+ "run sails on into plan and apply — a fail-closed refusal turned into a silent continue. You would\n",
+ "have to re-check `OLAF.last_result[\"status\"]` after every call and raise by hand, which is\n",
+ "`notebook.run` rewritten badly.\n",
+ "\n",
+ "`notebook.run` also gives each stage its own session and its own parameters. For orchestration that\n",
+ "isolation is the feature, not the overhead — and it is what makes this notebook a faithful stand-in\n",
+ "for a pipeline with one activity per stage.\n",
+ "\n",
+ "## How a stage reports back\n",
+ "\n",
+ "`olaf` ends every mode with one envelope:\n",
+ "\n",
+ "```\n",
+ "{ \"mode\" the stage that ran \"data\" per-stage result keys\n",
+ " \"status\" success | skipped \"batch_id\" ties plan to apply\n",
+ " blocked | error \"config_hash\" which config version\n",
+ " \"changed\" is there drift to apply \"message\" the one-line verdict }\n",
+ "```\n",
+ "\n",
+ "| status | What `olaf` does | What this notebook sees |\n",
+ "|---|---|---|\n",
+ "| `success` · `skipped` | `notebook.exit(envelope)` | `notebook.run` returns the JSON above |\n",
+ "| `blocked` · `error` | **raises** | the cell fails and the run stops here |\n",
+ "\n",
+ "A failed stage still stops the notebook, so the calling pipeline takes its Failure path — but it is\n",
+ "not allowed to stop *silently*. Every cell goes through `run_stage` or its own try, which prints the\n",
+ "stage, the capped raise payload and this run's log rows **before** re-raising. The `if`s below branch\n",
+ "on *outcomes*; failures are reported and rethrown, never swallowed.\n",
+ "\n",
+ "> It also means `blocked` and `error` never arrive as a *value*. Any variable that `json.loads`\n",
+ "> succeeded on is `success` or `skipped` — nothing else. The one stage where that costs something\n",
+ "> is `apply`, which has already written to the live role set by the time it can fail, so that cell\n",
+ "> prints the log row before letting the failure through.\n"
]
},
{
"cell_type": "markdown",
- "id": "workflow-params-doc",
+ "id": "c98064f94b4c",
"metadata": {},
"source": [
"## Parameters\n",
"\n",
- "The public defaults stop before all external work. A reviewer must deliberately enable an authorized, isolated, non-production evaluation. Never put real tenant, workspace, item, customer, or principal values in this file.\n"
+ "This cell is tagged `parameters`: Fabric and papermill **replace it wholesale** at run time, so the\n",
+ "values below are only what a manual run falls back on. Keep it to bare assignments — anything else\n",
+ "written here is lost on a pipeline run.\n",
+ "\n",
+ "| Parameter | Default | What it does |\n",
+ "|-------------------|------------------------------------------|----------------------------------------------------------------------------|\n",
+ "| `env` | `\"dev\"` | Tags every log row, so one estate's dev and prod audit trails stay apart. |\n",
+ "| `auto_approve` | `False` | The gate. `True` lets `apply` run unattended — nobody reviews the omissions. |\n",
+ "| `keep_unmanaged` | `False` | `False` = config is the whole truth: a live role it omits is left out of the payload. |\n",
+ "| `run_setup` | `False` | `True` after a framework upgrade — `setup` migrates control-table columns. |\n",
+ "| `load_config` | `False` | `True` re-reads the authored workbook into the config + member tables. |\n",
+ "| `config_workbook` | `\"Files/security/onelake_security.xlsx\"` | The authored workbook, resolved on the attached lakehouse. |\n",
+ "| `batch_id` | `\"\"` | Correlates this run's log rows. Empty = this notebook generates one. |\n"
]
},
{
"cell_type": "code",
- "id": "workflow-params",
"execution_count": null,
+ "id": "01312ee005c9",
"metadata": {
"tags": [
"parameters"
@@ -51,93 +182,588 @@
},
"outputs": [],
"source": [
- "lakehouse_name = \"SyntheticEvaluationLakehouse\"\n",
- "authorized_evaluation = False\n",
- "external_access_reviewed = False\n",
- "workspace_isolation_attested_for_run = False\n",
- "dar_snapshot_and_etag_captured = False\n",
- "sentinel_ready = False\n",
- "enable_sensitive_operations = False\n",
- "synthetic_workbook_path = \"\"\n"
+ "env = \"dev\"\n",
+ "lakehouse_name = \"YourLakehouse\" # the attached lakehouse this run secures (setup asserts it)\n",
+ "auto_approve = False\n",
+ "keep_unmanaged = False\n",
+ "run_setup = False\n",
+ "load_config = False\n",
+ "config_workbook = \"Files/security/onelake_security.xlsx\"\n",
+ "batch_id = \"\""
]
},
{
"cell_type": "code",
- "id": "workflow-guard",
"execution_count": null,
+ "id": "f6e6474947af",
"metadata": {},
"outputs": [],
"source": [
+ "import json\n",
+ "import re\n",
+ "import uuid\n",
+ "from collections import Counter\n",
+ "\n",
"import notebookutils\n",
"\n",
+ "# ── run constants ─────────────────────────────────────────────────────────────\n",
+ "# OLAF_NB the OLAF runtime notebook, by name, for notebook.run()\n",
+ "# LAKEHOUSE the attached lakehouse this run secures — from the lakehouse_name\n",
+ "# PARAMETER above, so a pipeline can set it per run (it was a hardcoded\n",
+ "# constant here through 1.0.x, invisible to Base parameters)\n",
+ "# AUTO_APPROVE True = run apply · False = stop after plan\n",
+ "# KEEP_UNMANAGED False = config is whole truth · omits roles absent from the config\n",
+ "# LOAD_CONFIG True = reload the workbook into the config + member tables\n",
+ "# WORKBOOK the authored workbook, resolved on the attached lakehouse\n",
+ "# BATCH_ID the passed-in batch_id, or a fresh one when none was supplied\n",
+ "#\n",
+ "# Captured by value, on purpose: cell 7's `%run olaf` rebinds the lowercase names, so anything\n",
+ "# read after it must already live in UPPER_CASE or in PARAMS.\n",
+ "OLAF_NB = \"olaf\"\n",
"LAKEHOUSE = lakehouse_name\n",
- "required_controls = {\n",
- " \"lakehouse_name\": bool(LAKEHOUSE.strip()),\n",
- " \"synthetic_workbook_path\": bool(synthetic_workbook_path.strip()),\n",
- " \"authorized_evaluation\": authorized_evaluation,\n",
- " \"external_access_reviewed\": external_access_reviewed,\n",
- " \"workspace_isolation_attested_for_run\": workspace_isolation_attested_for_run,\n",
- " \"dar_snapshot_and_etag_captured\": dar_snapshot_and_etag_captured,\n",
- " \"sentinel_ready\": sentinel_ready,\n",
- " \"enable_sensitive_operations\": enable_sensitive_operations,\n",
+ "AUTO_APPROVE = auto_approve\n",
+ "KEEP_UNMANAGED = keep_unmanaged\n",
+ "LOAD_CONFIG = load_config\n",
+ "WORKBOOK = config_workbook\n",
+ "BATCH_ID = batch_id.strip() or str(uuid.uuid4())\n",
+ "\n",
+ "# Setup override parameters\n",
+ "PARAMS = {\n",
+ " \"env\": env,\n",
+ " \"batch_id\": BATCH_ID,\n",
+ " # Schema-qualified names for the four control tables. These are OLAF's own defaults; point them\n",
+ " # wherever your estate keeps them — every stage below and the facade all read the same PARAMS,\n",
+ " # so they cannot drift apart.\n",
+ " \"config_table\": \"olaf.onelake_security_config\",\n",
+ " \"mapping_table\": \"olaf.onelake_security_mapping\",\n",
+ " \"member_table\": \"olaf.onelake_security_member\",\n",
+ " \"log_table\": \"olaf.onelake_security_log\",\n",
"}\n",
- "missing = [name for name, value in required_controls.items() if not value]\n",
- "if missing:\n",
- " notebookutils.notebook.exit(\n",
- " \"OLAF Preview workflow stopped; disabled controls: \" + \", \".join(missing)\n",
- " )\n"
+ "\n",
+ "print(\n",
+ " \"batch_id\",\n",
+ " BATCH_ID,\n",
+ " \"(passed in)\" if batch_id.strip() else \"(generated)\",\n",
+ " \"· AUTO_APPROVE\",\n",
+ " AUTO_APPROVE,\n",
+ ")\n",
+ "\n",
+ "\n",
+ "# ── output helpers ────────────────────────────────────────────────────────────\n",
+ "STAGE_ICON = {\n",
+ " \"setup\": \"🧱\",\n",
+ " \"validate\": \"🔍\",\n",
+ " \"generate\": \"🧬\",\n",
+ " \"plan\": \"📋\",\n",
+ " \"apply\": \"🚀\",\n",
+ " \"rollback\": \"↩️\",\n",
+ " \"trace\": \"🔭\",\n",
+ " \"show\": \"👥\",\n",
+ " \"log\": \"📜\",\n",
+ "}\n",
+ "STATUS_BADGE = {\"success\": \"✅\", \"skipped\": \"⏭️\", \"blocked\": \"🚫\", \"error\": \"❌\"}\n",
+ "\n",
+ "\n",
+ "def _flatten(data, _prefix=\"\"): # envelope data -> (item, value) rows\n",
+ " rows = []\n",
+ " for k, v in (data or {}).items():\n",
+ " if isinstance(v, dict):\n",
+ " rows += [(f\"{_prefix}{k}.{k2}\", str(v2)) for k2, v2 in v.items()] or [\n",
+ " (f\"{_prefix}{k}\", \"(none)\")\n",
+ " ]\n",
+ " elif isinstance(v, (list, tuple)):\n",
+ " rows.append((f\"{_prefix}{k}\", \", \".join(map(str, v)) if v else \"(none)\"))\n",
+ " else:\n",
+ " rows.append((f\"{_prefix}{k}\", str(v)))\n",
+ " return rows\n",
+ "\n",
+ "\n",
+ "def report(stage, env, detail=True): # header + message + data table\n",
+ " icon, status = STAGE_ICON.get(stage, \"•\"), env.get(\"status\", \"?\")\n",
+ " print(f\"{icon} {stage.upper():9} {STATUS_BADGE.get(status, '•')} {status}\")\n",
+ " print(f\" {env.get('message', '')}\")\n",
+ " rows = _flatten(env.get(\"data\")) if detail else []\n",
+ " if rows:\n",
+ " display(spark.createDataFrame(rows, \"item STRING, value STRING\"))\n",
+ " return env\n",
+ "\n",
+ "\n",
+ "def role_actions(env, key=\"plan\"): # per-role verdicts, omissions first\n",
+ " d = (env.get(\"data\") or {}).get(key) or {}\n",
+ " if not d:\n",
+ " return\n",
+ " # `omit`, not `delete`: a role left out of the payload is an omission candidate. The\n",
+ " # runtime has never emitted \"delete\" here, so an order keyed on it sorted omissions last.\n",
+ " order = {\"omit\": 0, \"update\": 1, \"create\": 2}\n",
+ " rows = sorted(((r, a) for r, a in d.items()), key=lambda x: (order.get(x[1], 9), x[0]))\n",
+ " print(f\" {key} — {len(rows)} role(s), omissions first\")\n",
+ " display(spark.createDataFrame(rows, \"role STRING, action STRING\"))\n",
+ "\n",
+ "\n",
+ "def fail_report(stage, exc): # what the pipeline gets to see on a failure\n",
+ " print(f\"❌ {stage.upper():9} FAILED\")\n",
+ " print(f\" {str(exc)[:1200]}\")\n",
+ " try: # validate writes no log row; the rest do\n",
+ " rows = spark.table(PARAMS[\"log_table\"]).where(f\"batch_id = '{BATCH_ID}'\").orderBy(\"run_at\")\n",
+ " display(rows) if rows.count() else print(\n",
+ " \" (no log row — validate never logs; reason is above)\"\n",
+ " )\n",
+ " except Exception as e:\n",
+ " print(\" (log unreadable:\", e, \")\")\n",
+ "\n",
+ "\n",
+ "def run_stage(stage, extra=None, detail=True): # notebook.run + report · the CELL owns the try\n",
+ " return report(\n",
+ " stage,\n",
+ " json.loads(\n",
+ " notebookutils.notebook.run(OLAF_NB, 3600, {**PARAMS, \"mode\": stage, **(extra or {})})\n",
+ " ),\n",
+ " detail=detail,\n",
+ " )\n",
+ "\n",
+ "\n",
+ "_RULE = re.compile(r\"\\s*\\(rule (\\w+)\\)\\s*$\") # MOST warnings end this way; cross-row\n",
+ "# and platform-limit warnings carry no \"(rule XX)\" suffix and land together under \"?\"\n",
+ "_ROW = re.compile(r\"^row (\\d+) \\(([^)]*)\\):\\s*\") # per-row warnings name their role\n",
+ "\n",
+ "\n",
+ "def warnings_table(env): # validate's warnings, one row each\n",
+ " ws = (env.get(\"data\") or {}).get(\"warnings\") or []\n",
+ " if not ws:\n",
+ " print(\" ✅ no warnings\")\n",
+ " return\n",
+ " rows = []\n",
+ " for w in ws:\n",
+ " text = str(w)\n",
+ " rule = _RULE.search(text)\n",
+ " text = _RULE.sub(\"\", text) if rule else text\n",
+ " row = _ROW.match(text)\n",
+ " rows.append(\n",
+ " (\n",
+ " rule.group(1) if rule else \"?\",\n",
+ " row.group(1) if row else \"\", # config row number, when it has one\n",
+ " row.group(2) if row else \"\", # the role it is about\n",
+ " _ROW.sub(\"\", text).strip(),\n",
+ " )\n",
+ " )\n",
+ " by_rule = Counter(r[0] for r in rows)\n",
+ " print(\n",
+ " \" ⚠️ \"\n",
+ " + str(len(rows))\n",
+ " + \" warning(s) · \"\n",
+ " + \" · \".join(f\"{k} × {v}\" for k, v in sorted(by_rule.items()))\n",
+ " )\n",
+ " # rarest rule first: one C4 among thirty B4s is the one worth reading\n",
+ " rows.sort(key=lambda r: (by_rule[r[0]], r[0], int(r[1] or 0)))\n",
+ " display(spark.createDataFrame(rows, \"rule STRING, row STRING, subject STRING, warning STRING\"))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c98bd3b3c0fa",
+ "metadata": {},
+ "source": [
+ "## 1 · setup\n",
+ "\n",
+ "Creates the control tables if they are missing. Idempotent, so it is safe on every run.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fbf07db53781",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " if run_setup:\n",
+ " setup = run_stage(\"setup\", {\"lakehouse_name\": LAKEHOUSE})\n",
+ " else:\n",
+ " setup = None\n",
+ " print(\"⏭️ SETUP skipped — set run_setup = True after a framework upgrade\")\n",
+ "except Exception as exc:\n",
+ " fail_report(\"setup\", exc)\n",
+ " raise"
]
},
{
"cell_type": "markdown",
- "id": "workflow-controls",
+ "id": "da23b3e60022",
"metadata": {},
"source": [
- "## Required control sequence\n",
+ "## 1b · load the authored workbook → config + member\n",
"\n",
- "The technical DAR snapshot plus immutable ETag is separate from the per-run workspace-isolation attestation. Recompute both for the current run. OLAF creates/reads the sentinel before the first sensitive write and revalidates it with the snapshot before every sensitive write; an earlier check is not enough. Sensitive modes include first setup, workbook load, generate, apply, rollback, reset, and cleanup.\n",
+ "`load_config` is a **facade** call, not a mode, so the notebook loads `olaf` as a library first with\n",
+ "`%run`. That single `%run` serves this stage and the verify stage at the end.\n",
"\n",
- "Do not upload a real workbook before the external-access review. A same-lakehouse layout is not cryptographic or transactional isolation. Cleanup is containment and does not prove erasure or remove external copies.\n"
+ "> 🔴 **`OLAF.configure(**PARAMS)` is not optional, and it must come before any facade call.** `%run`\n",
+ "> brings in olaf's *own* parameter-cell defaults, which are not necessarily the tables this run uses.\n",
+ "> Without it, `load_config` would write to a different set of control tables than the stages below\n",
+ "> read. `configure` makes PARAMS sticky for every later facade call and hands back a frame of what is\n",
+ "> set, so the output is the receipt.\n",
+ ">\n",
+ "> `%run` also rebinds the loose lowercase names (`env`, `batch_id`, `config_table`, …). Everything\n",
+ "> this notebook needs afterwards is either `PARAMS` (a dict olaf never defines) or UPPER_CASE, which\n",
+ "> is why it survives — do not add lowercase state you expect to outlive this cell.\n",
+ ">\n",
+ "> **This is why a re-run starts at the top, not at the stage you want to redo.** After this cell,\n",
+ "> `env` is back to olaf's own default and `batch_id` is `\"\"` — of the switches in the parameters\n",
+ "> cell, `env`, `batch_id` and `keep_unmanaged` are the three olaf also defines. Re-running from cell\n",
+ "> 3 onwards would silently log a `prod` run as `dev`, or turn a deliberate `keep_unmanaged = True`\n",
+ "> back into a destructive REPLACE, with no error either way. Likewise `LOAD_CONFIG` / `AUTO_APPROVE`\n",
+ "> are **captured** in cell 3: flipping the lowercase switch takes effect only once cell 3 re-runs.\n",
+ "\n",
+ "A full REPLACE from the workbook: a row deleted there is deleted here. The sheet's columns must match\n",
+ "the table exactly — missing *and* unexpected are both refused, naming them, because a sheet loaded\n",
+ "with a column missing is a config that silently means something other than what the author edited.\n",
+ "\n",
+ "No audit row is written; the Delta commit is the record. This is authoring, not deployment."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "798fde952a6e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%run olaf"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "71ffbb76a793",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# same PARAMS as every stage · verbosity=\"quiet\" drops olaf's result-key dump\n",
+ "try:\n",
+ " print(\"⚙️ CONFIGURE facade pointed at this run's control tables\")\n",
+ " display(OLAF.configure(**PARAMS, verbosity=\"quiet\"))\n",
+ "except Exception as exc:\n",
+ " fail_report(\"configure\", exc)\n",
+ " raise\n",
+ "\n",
+ "try:\n",
+ " if LOAD_CONFIG:\n",
+ " print(f\"📥 LOAD {WORKBOOK} → config + member (full REPLACE)\")\n",
+ " display(OLAF.load_config(\"config\", WORKBOOK, sheet=\"config\"))\n",
+ " display(OLAF.load_config(\"member\", WORKBOOK, sheet=\"member\"))\n",
+ " else:\n",
+ " print(\"⏭️ LOAD skipped — set load_config = True and re-run from the top\")\n",
+ "except Exception as exc:\n",
+ " fail_report(\"load_config\", exc)\n",
+ " raise"
]
},
{
"cell_type": "markdown",
- "id": "workflow-sequence",
+ "id": "003402f0f3ce",
"metadata": {},
"source": [
- "## Adapted sequence — documentation only\n",
+ "## 2 · validate\n",
"\n",
- "Review the current runtime parameter contract before uncommenting or moving any step:\n",
+ "Runs every rule `generate` runs, against the config, and writes **nothing** — no mapping, no CSV,\n",
+ "not even a log row. So a config that is going to be refused is refused here, before anything has\n",
+ "been committed, and the existing lock-file is left exactly as it was.\n",
"\n",
- "```python\n",
- "# 1. Recompute the per-run isolation attestation.\n",
- "# 2. Capture an immutable DAR snapshot and its current ETag.\n",
- "# 3. Run read/validation stages and review the exact request.\n",
- "# 4. Create/read the sentinel before the first sensitive write.\n",
- "# The runtime revalidates the sentinel and snapshot before every sensitive write.\n",
- "# 5. Invoke one sensitive stage with explicit enablement.\n",
- "# 6. Re-read the DAR, compare post-state, and contain unexpected state.\n",
- "```\n",
+ "It also collects *all* failures rather than stopping at the first, so one run lists everything\n",
+ "wrong with the config.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "474dc9cf080f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " validate = run_stage(\"validate\")\n",
+ " warnings_table(validate)\n",
+ "except Exception as exc:\n",
+ " fail_report(\"validate\", exc)\n",
+ " raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9f9511176203",
+ "metadata": {},
+ "source": [
+ "## 3 · generate\n",
+ "\n",
+ "Now that the config is known good, commit it: resolve it into the mapping lock-file that `plan` and\n",
+ "`apply` read, and export the versioned CSV to the mapping-history folder.\n",
"\n",
- "`keep_unmanaged` describes OLAF request construction only. The Preview endpoint does not document atomic full-set replacement or deletion-by-omission. Backups support recovery decisions; they do not guarantee exact restoration.\n"
+ "`skipped` means the config hash has not changed and the existing lock-file is still current — that\n",
+ "is a pass, not a problem.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6aeaf677a915",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " generate = run_stage(\"generate\")\n",
+ "\n",
+ " if generate[\"status\"] == \"skipped\":\n",
+ " print(\" → config unchanged, reusing the current lock-file\")\n",
+ "except Exception as exc:\n",
+ " fail_report(\"generate\", exc)\n",
+ " raise"
]
},
{
"cell_type": "markdown",
- "id": "workflow-evidence",
+ "id": "809c868182f2",
"metadata": {},
"source": [
- "## Evidence and reporting\n",
+ "## 4 · plan\n",
+ "\n",
+ "Diffs the mapping against the live roles and writes the plan to the log. **No live change.**\n",
"\n",
- "If an authorized evaluation is performed, bind evidence to the exact commit SHA and record runtime/API versions, engine/access mode, authorization, restoration, and cleanup. Store only redacted evidence. This repository does not claim that v1.0.0 was live-verified.\n",
+ "`changed` is the branch: no drift means the deployment is already correct, so apply never runs and\n",
+ "nobody is asked to approve an empty plan.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8907d629e275",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " plan = run_stage(\"plan\", detail=False)\n",
+ " role_actions(plan, \"plan\")\n",
+ "\n",
+ " print(\n",
+ " \"\\n → changes to apply — approval required\"\n",
+ " if plan[\"changed\"]\n",
+ " else \"\\n ✅ no drift — nothing to apply, the run ends here\"\n",
+ " )\n",
+ "except Exception as exc:\n",
+ " fail_report(\"plan\", exc)\n",
+ " raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c82f3f39fdcf",
+ "metadata": {},
+ "source": [
+ "## 5 · gate → apply\n",
+ "\n",
+ "The only human step. In ADF this is an approval check; here it is `AUTO_APPROVE`.\n",
+ "\n",
+ "**What actually binds apply to the plan is `config_hash`, not `batch_id`.** `apply` refuses unless\n",
+ "the log holds a successful `plan` row for this env and this same config hash, *and* the live state\n",
+ "still matches what that plan diffed against — edit the config between the two and apply refuses\n",
+ "rather than deploying a plan nobody reviewed. `batch_id` only correlates the run's log rows: it is\n",
+ "not in the gate's filter, so a shared batch_id neither grants nor withholds anything.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3576a5432b5e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "apply_result = None # bound in every branch, so the exit cell can read it safely\n",
+ "\n",
+ "try:\n",
+ " if not plan[\"changed\"]:\n",
+ " print(\"⏭️ APPLY skipped — nothing to apply\")\n",
+ "\n",
+ " elif not AUTO_APPROVE:\n",
+ " print(\"⏸️ APPLY held — set auto_approve = True and re-run from the top to apply\")\n",
+ "\n",
+ " else:\n",
+ " apply_result = run_stage(\"apply\", {\"keep_unmanaged\": KEEP_UNMANAGED}, detail=False)\n",
+ " # apply's envelope carries no per-role map, so there is no role_actions() call here: the\n",
+ " # plan cell above already printed the verdicts, and what apply adds is what it actually did.\n",
+ " # (An `applied` key existed once, renamed `push_status` because it holds the bulk PUT's\n",
+ " # HTTP 200 -- \"applied: 200\" reads as 200 roles to anyone skimming an incident.)\n",
+ " done = apply_result.get(\"data\") or {}\n",
+ " wrote, http = done.get(\"roles_written\", \"?\"), done.get(\"push_status\", \"?\")\n",
+ " print(f\" ✍️ wrote {wrote} role(s) · HTTP {http}\")\n",
+ " gone = done.get(\"omitted_role_candidates\") or []\n",
+ " # CANDIDATES, not confirmed deletions: the Preview bulk endpoint does not document\n",
+ " # deletion-by-omission, so what OLAF can say is which roles it left out of the payload.\n",
+ " print(f\" ➖ omitted {len(gone)}:\", \", \".join(gone) if gone else \"none\")\n",
+ " if gone:\n",
+ " print(\n",
+ " \" ↳ omission is a REQUEST shape, not a confirmed outcome — check the post-state\"\n",
+ " )\n",
+ " print(\" 💾 backup:\", done.get(\"backup_path\", \"—\"))\n",
+ "except Exception as exc:\n",
+ " fail_report(\"apply\", exc)\n",
+ " raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7998a857b660",
+ "metadata": {},
+ "source": [
+ "## 6 · verify — read the live state back\n",
+ "\n",
+ "The `OLAF` facade is already bound and configured — stage 1b did both. The facade is the interactive\n",
+ "surface: unlike the pipeline path it **never raises on outcome** and hands back a DataFrame, so this\n",
+ "cell is safe to re-run and safe to read.\n",
+ "\n",
+ "This is what turns \"apply returned success\" into \"the live role set looks like this\".\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bbfb559e8ee2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " print(\"🔭 TRACE operational snapshot — the current generation end to end\")\n",
+ " display(OLAF.trace())\n",
+ "\n",
+ " print(\"👥 SHOW the live role set, as it stands after the apply above\")\n",
+ " display(OLAF.show(by=\"role\", subject=\"*\")) # subject is REQUIRED · \"*\" = every role\n",
+ "except Exception as exc:\n",
+ " fail_report(\"verify\", exc)\n",
+ " raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5337b3feac35",
+ "metadata": {},
+ "source": [
+ "## What the run left behind\n",
+ "\n",
+ "`olaf.onelake_security_log` keeps every row this run wrote, keyed by `batch_id` — the durable audit\n",
+ "trail, readable long after this session is gone.\n",
+ "\n",
+ "The run timestamp column is `run_at` — not `logged_at`. The full column list is in [data-model.md](../docs/data-model.md).\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d454df3922de",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " print(f\"📜 LOG every row this run wrote · batch_id {BATCH_ID}\")\n",
+ " display(spark.table(PARAMS[\"log_table\"]).where(f\"batch_id = '{BATCH_ID}'\").orderBy(\"run_at\"))\n",
+ "except Exception as exc:\n",
+ " fail_report(\"log\", exc)\n",
+ " raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "037d45b94fc9",
+ "metadata": {},
+ "source": [
+ "## Recovery — two mechanisms, and they are not interchangeable\n",
+ "\n",
+ "`apply` defaults to config-as-whole-truth: it omits every live role the config does not declare from the submitted payload, **including\n",
+ "`Default*`**. There are two ways back, and picking the wrong one loses roles.\n",
+ "\n",
+ "| What went wrong | Use | Why |\n",
+ "|---|---|---|\n",
+ "| **apply failed part-way** — the push threw | the **pre-apply backup file** | it restores the live role set *exactly as it was*, including roles the config never declared |\n",
+ "| **apply succeeded, the config was wrong** | `rollback` | it restores a prior config version and re-runs generate → plan → apply |\n",
+ "\n",
+ "> 🔴 **`rollback` is not a substitute for the backup.** It replays *config*, so it cannot rebuild\n",
+ "> roles config never declared — and that is precisely the set a default `apply` leaves out. After a\n",
+ "> failed or mistaken run, the backup file is the recovery input for putting `Default*` back; it is a\n",
+ "> recovery *input*, not a guaranteed exact restoration of platform state.\n",
+ "\n",
+ "**The backup is automatic.** `apply` writes the live roles to `Files/security/role-backups/` before\n",
+ "it pushes, on every run, and **a failed backup aborts the apply** — nothing is pushed without a\n",
+ "restore point behind it. The path is printed by the apply cell above and carried in the envelope as\n",
+ "`data.backup_path`. Restoring is `json.load` → `put_roles`, nothing to edit in between\n",
+ "(RUNBOOK §3c).\n",
+ "\n",
+ "**A failed push is not silent either.** Before the exception leaves `apply`, the framework re-reads\n",
+ "the live roles and writes a forensic record to the log: the per-grant rows re-stamped `failed` (so a\n",
+ "push that wrote nothing cannot later claim it wrote everything) and one row per planned role saying\n",
+ "whether it is `PRESENT` or `ABSENT` live. Read it with the log cell above before deciding anything.\n",
+ "\n",
+ "Rollback is left commented below on purpose — an escape hatch, not part of the routine flow.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5838b6fe114d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Uncomment to roll back. rollback_to_version \"\" = the version immediately before the current one.\n",
+ "# try:\n",
+ "# rb = run_stage(\"rollback\", {\"rollback_to_version\": \"\",\n",
+ "# \"rollback_reason\": \"why this deployment is being withdrawn\"})\n",
+ "# except Exception as exc:\n",
+ "# fail_report(\"rollback\", exc)\n",
+ "# raise"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "95182f6445b7",
+ "metadata": {},
+ "source": [
+ "## Hand the result back to the caller\n",
+ "\n",
+ "`notebook.exit` ends the notebook and returns this payload as the activity's `exitValue`, so the\n",
+ "pipeline that called this one can branch on whether anything actually changed instead of\n",
+ "re-querying the log.\n",
+ "\n",
+ "Failures never reach here: a failed stage raises, the notebook fails, and the calling activity takes\n",
+ "its own **Failure** path.\n",
+ "\n",
+ "**Keep this cell last.** `notebook.exit` stops execution, so anything below it would never run.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ff454cfd6aca",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " result = {\n",
+ " \"batch_id\": BATCH_ID,\n",
+ " \"changed\": plan[\"changed\"], # did plan find drift to apply\n",
+ " \"applied\": apply_result is not None, # did apply actually run\n",
+ " \"backup_path\": (apply_result or {}).get(\"data\", {}).get(\"backup_path\"),\n",
+ " }\n",
+ " print(\"🏁 DONE returning to the caller\")\n",
+ " display(\n",
+ " spark.createDataFrame([(k, str(v)) for k, v in result.items()], \"item STRING, value STRING\")\n",
+ " )\n",
+ "except Exception as exc:\n",
+ " fail_report(\"done\", exc)\n",
+ " raise\n",
"\n",
- "Official references: [bulk DAR endpoint](https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-data-access-security/create-or-update-data-access-roles), [workspace permissions](https://learn.microsoft.com/en-us/fabric/onelake/security/data-access-control-model#onelake-security-and-workspace-permissions), and [engine/user access](https://learn.microsoft.com/en-us/fabric/onelake/security/data-access-control-model#engine-and-user-access-to-data).\n"
+ "notebookutils.notebook.exit(json.dumps(result))"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Synapse PySpark",
+ "language": "Python",
"name": "synapse_pyspark"
},
"language_info": {