diff --git a/AGENTS.md b/AGENTS.md
index 3a16657..5d3f856 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@ CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo ga
How tests are split:
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
-- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets access, `writer-*` gets access plus write plus share, anything else gets nothing.
+- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets the user permission list, `writer-*` adds write and share, `admin-*` gets the admin list, anything else gets nothing. `loginAs(user, groups...)` / `as(user)` also apply the security keys; notebook calls must run with the matching session. `TableSecurityMatrixTest` is the role x table x action matrix.
## Architecture
@@ -37,15 +37,19 @@ How tests are split:
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
-- `notebook/`: `NotebookService` (CRUD, visibility, versions in one transaction, snapshots).
+- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
-- `metadata/`: the 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
+- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
+- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
+- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
**Invariants (do not break):**
- Never log SQL text, parameters, or results. Only the audit table holds them.
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
+- Workbench rows are read and written as the caller (record locks apply). Only the audit writes, version bookkeeping, cascades and share-visibility sync run as the system user, after QQQ authorized the change. Never re-hide the tables or bypass the locks to fix a permission problem.
+- Table customizers hold the notebook invariants; the notebook UI and the table UI must behave the same.
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
## Code Style
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 893d28d..3c72411 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,28 @@
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
+## [1.0.0-RC.2] - Unreleased
+
+The workbench's data becomes standard QQQ tables, secured by QQQ's own mechanisms instead of being hidden ([#3](https://github.com/QRun-IO/qbit-sql-workbench/issues/3)). Requires QQQ `4.1.0-RC.4` or later ([qqq#1073](https://github.com/QRun-IO/qqq/issues/1073)).
+
+### Added
+- `sqlWorkbenchNotebookShare`: share a notebook, read-only, with a user, a group, or everyone (`ALL`, the shared library). Publishing in the notebook UI now writes an `ALL` share; a notebook's `visibility` follows its shares.
+- Record security locks on every table, enforced by QQQ's core actions (table UI, `/qqq/v1` table API, export, PVS lookups, processes, the workbench endpoints): owners see and change their notebooks and children, share audiences read them, users read their own execution audit, admins (`sqlWorkbenchAdmin.hasAccess`) hold the all-access key. Security key types `sqlWorkbenchUser` and `sqlWorkbenchAudience`; `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` puts the keys in a session.
+- Per-table `read` / `insert` / `edit` / `delete` permissions, listed per role by `SqlWorkbenchQBitConfig.getUserPermissionNames()`, `getSharePermissionNames()` and `getAdminPermissionNames()`.
+- All six tables are children of the `sqlWorkbench` app, with record-view sections, child-list widgets (cells, shares, pinned results, versions) and SQL / JSON code-editor adornments.
+- Record actions (processes on tables, not in the nav): Share Notebook, Duplicate Notebook, Restore Version.
+- Table customizers that keep the notebook invariants for every entry point: ownership from the session, cell validation and ordering, one version per change, share keys and visibility, append-only versions and audit (also for admins), pinned results only from pinning, cascading notebook deletes.
+- Record-level QQQ audits for notebooks, cells, pinned results and shares.
+
+### Changed
+- `NotebookService` and the history endpoint run as the caller's session through the standard table actions (no system-user bypass); the explicit owner / share checks stay as defense in depth.
+- The tables' permission rules are per-table permissions instead of `sqlWorkbenchAdmin.hasAccess`. Hosts must grant the new lists and call `applyToSession`.
+- Built against QQQ `4.1.0-RC.4`.
+
+### Upgrade from RC.1
+- Run the share-table DDL from `docs/ddl/postgres.sql`: it creates `sql_workbench_notebook_share` and backfills an `all` share for each `SHARED` notebook (additive, idempotent; owners unchanged).
+- Grant the permission lists and call `SqlWorkbenchSecurity.applyToSession` in the authentication customizer.
+
## [1.0.0-RC.1] - 2026-10-11
First release candidate of 1.0, published to Maven Central from `release/1.0`. It contains every change listed under 1.0.0 below. Requires QQQ 4.1 (built against 4.1.0-RC.2) and a Next UI release that renders the `SQL_WORKBENCH` widget (`v1.0.0-RC.13` or later).
diff --git a/CLAUDE.md b/CLAUDE.md
index 08e3ea5..e9b8213 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,7 +18,7 @@ CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo ga
How tests are split:
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
-- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets access, `writer-*` gets access plus write plus share, anything else gets nothing.
+- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets the user permission list, `writer-*` adds write and share, `admin-*` gets the admin list, anything else gets nothing. `loginAs(user, groups...)` / `as(user)` also apply the security keys; notebook calls must run with the matching session. `TableSecurityMatrixTest` is the role x table x action matrix.
## Architecture
@@ -37,15 +37,19 @@ How tests are split:
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
-- `notebook/`: `NotebookService` (CRUD, visibility, versions in one transaction, snapshots).
+- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
-- `metadata/`: the 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
+- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
+- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
+- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
**Invariants (do not break):**
- Never log SQL text, parameters, or results. Only the audit table holds them.
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
+- Workbench rows are read and written as the caller (record locks apply). Only the audit writes, version bookkeeping, cascades and share-visibility sync run as the system user, after QQQ authorized the change. Never re-hide the tables or bypass the locks to fix a permission problem.
+- Table customizers hold the notebook invariants; the notebook UI and the table UI must behave the same.
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
## Code Style
diff --git a/README.md b/README.md
index 173e927..78c7d00 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,8 @@ You write SQL and markdown in notebook cells, run them against any RDBMS backend
- **Read-only by default.** A default session runs one statement inside a database `READ ONLY` transaction, and a statement classifier refuses anything that isn't a read.
- **Gated write mode.** Writes need a separate permission and two steps. First a dry run executes in a transaction that is rolled back, so you see the affected-row counts. You then confirm with a single-use token bound to that exact SQL. A row guard, a hard cap, a drift check, and statement timeouts apply.
- **Everything is audited.** The `sqlWorkbenchExecution` table records who ran what, when, on which backend, in which mode, how long it took, how many rows it returned or affected, and any error. SQL text and results never go to the logs.
-- **Notebooks.** Notebooks are personal by default and can be published to a shared library. They have named `:parameters` (always bound, never spliced), version history, and pinned result snapshots.
+- **Notebooks.** Notebooks are personal by default and can be shared, read-only, with users, groups, or everyone (the shared library). They have named `:parameters` (always bound, never spliced), version history, and pinned result snapshots.
+- **Standard QQQ tables.** Notebooks, cells, versions, pinned results, shares, and the execution audit are ordinary QQQ tables in the app, with the usual query, record, edit, export, audit, and developer-mode screens. Record security locks decide who sees and changes which rows, for every entry point.
> **Raw SQL bypasses QQQ.** It ignores record security locks, table and field permissions, hidden fields, and record locks. It also skips QQQ audits, record-change listeners, automations, ESB events, and search indexing. That is why the workbench is for super-users only. Use QQQ processes for business changes.
@@ -15,7 +16,7 @@ The full design (architecture, data model, security model, limits, API contract,
## Requirements
-- Java 21 and QQQ 4.1 (built against `4.1.0-RC.2`).
+- Java 21 and QQQ 4.1 (built against `4.1.0-RC.4`, which is required: it lets partial edits of child rows pass joined record locks, [qqq#1073](https://github.com/QRun-IO/qqq/issues/1073)).
- `qqq-backend-module-rdbms` for the targets. `qqq-middleware-javalin` for the REST routes; it is optional, and without it the engine still works but no routes are registered.
- A Next UI release that renders the `SQL_WORKBENCH` widget.
@@ -25,13 +26,13 @@ The full design (architecture, data model, security model, limits, API contract,
com.kingsrook.qbits
qbit-sql-workbench
- 1.0.0-RC.1
+ 1.0.0-RC.2
```
```java
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig()
- .withBackendName("postgres") // where the qbit's 5 tables live
+ .withBackendName("postgres") // where the qbit's 6 tables live
.withTableMetaDataCustomizer(new SnakeCaseCustomizer()) // optional: map names to snake_case
.withReadOnlyCredentials("postgres", new SqlWorkbenchCredentials()
.withUsername("${env.SQLWB_RO_USER}") // optional, recommended: a SELECT-only role
@@ -42,17 +43,20 @@ new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
The producer adds the following to the instance:
-- **Tables:** `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`, `sqlWorkbenchExecution`.
-- **A possible value source** over the notebook table.
+- **Tables:** `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`, `sqlWorkbenchNotebookShare`, `sqlWorkbenchExecution`, each with record security locks, per-table permissions, and table customizers ([Permissions](#permissions)).
+- **Security key types** `sqlWorkbenchUser` (all-access key `sqlWorkbenchAllAccess`) and `sqlWorkbenchAudience`.
+- **Joins** from a notebook to its cells, versions, pinned results, and shares, with child-list widgets on the notebook's record view.
+- **Possible value sources** over the notebook table and the share audience types.
+- **Record actions** (processes on a table, never in the nav): Share Notebook, Duplicate Notebook, Restore Version.
- **The `sqlWorkbenchWidget` widget** (type `SQL_WORKBENCH`).
-- **The `sqlWorkbench` app**, which contains the widget plus the notebook and audit tables. It has no processes.
+- **The `sqlWorkbench` app**, which contains the widget plus all six tables.
- **The `/qqq/v1/sql-workbench/*` routes**, registered through `QJavalinMetaData`, as `qqq-esb` does.
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing.
## Storage
-Create the five tables on `backendName`. [docs/ddl/postgres.sql](docs/ddl/postgres.sql) has idempotent PostgreSQL DDL that you can paste into a Liquibase formatted-SQL changeset.
+Create the six tables on `backendName`. [docs/ddl/postgres.sql](docs/ddl/postgres.sql) has idempotent PostgreSQL DDL that you can paste into a Liquibase formatted-SQL changeset.
The DDL uses snake_case names: `sql_workbench_notebook`, `owner_user_id`, and so on. Match them with a table customizer:
@@ -68,25 +72,60 @@ public class SnakeCaseCustomizer implements MetaDataCustomizerInterface`, `group:` for each group passed, and `all` | share locks (read only): a share row's `audienceKey` |
+| `sqlWorkbenchAllAccess` | `true` for holders of the admin permission | every row |
+
+A session without the access permission gets no keys, so it finds no workbench rows at all, even through code paths that skip table permissions. If the host never calls `applyToSession`, the workbench endpoints add the user and `all` keys themselves, but group shares need the host to pass the groups.
+
+**What the locks and customizers enforce:**
+
+| Table | Read | Write |
+|---|---|---|
+| Notebook | the owner, a share audience of the session, or an admin | the owner or an admin. The owner and visibility are system-managed. |
+| Cell, version, pinned result | as their notebook | the owner or an admin, through the notebook. Versions are append-only. |
+| Share | the notebook's owner, the audience it names, or an admin | the owner or an admin, with the share permission |
+| Execution (audit) | the user who ran it, or an admin | nobody: append-only, only the qbit writes it |
`writeModeEnabled=false` turns write mode off for everyone.
+## Administering through the tables
+
+Everything the notebook UI does can be done and inspected through the tables under the SQL Workbench app. The same customizers run either way.
+
+- **Notebooks**: create, rename, describe, delete (which removes its cells, versions, pinned results, and shares). The record view lists the notebook's cells, shares, pinned results, and versions. Record actions: Share Notebook and Duplicate Notebook.
+- **Cells**: add (appended at the end, with a generated key), edit, reorder (`sortOrder`), delete. Each change saves a new notebook version, exactly like a save in the notebook UI.
+- **Versions**: read-only history. Restore Version saves the chosen version as a new one.
+- **Shares**: who can read a notebook. `USER` and `GROUP` shares name their audience, matched without regard to case (so user ids and group names must be case-insensitive in the host); `ALL` publishes the notebook to the shared library. The notebook's `visibility` follows its shares.
+- **Pinned results**: relabel or delete. Only pinning a result in the notebook UI creates them.
+- **Execution audit**: read, filter, and export. Nobody can edit or delete it through QQQ.
+
+Notebooks, cells, pinned results, and shares get record-level QQQ audits, so SQL text, result data, and share audiences are not copied into the host's audit tables (whose readers the workbench locks do not reach).
+
## Safety model
> **Production: configure `readOnlyCredentials` for every target backend.** Use a dedicated database role with `SELECT` grants only: no write privileges, not a superuser, no `pg_signal_backend` (or MySQL `PROCESS` / `SUPER` / `CONNECTION_ADMIN`). Without it, read-only mode connects as the application's own role and relies on the classifier and the session's `READ ONLY` transaction alone. On H2 / generic backends there is no enforced read-only transaction, so the classifier is the only control. On Postgres a `READ ONLY` transaction still lets the application role call side-effecting functions such as `pg_terminate_backend()` or `pg_cancel_backend()`. `/capabilities` reports `readOnlyCredentials: false` / `readOnlyEnforced: false` per backend so you can check.
@@ -152,18 +191,20 @@ A checklist for adding the workbench to a QQQ application. Each row points to th
| Item | What to do |
|---|---|
-| Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.1` (Maven Central, published from `release/1.0`). |
-| QQQ version | QQQ 4.1, `4.1.0-RC.2` or later. Java 21. |
+| Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.2` (Maven Central, published from `release/1.0`). |
+| QQQ version | QQQ 4.1, `4.1.0-RC.4` or later (qqq#1073: partial edits of child rows under joined locks). Java 21. |
| Dependencies | `qqq-backend-module-rdbms` comes in transitively. Add `qqq-middleware-javalin` yourself for the REST routes; it is optional in this qbit's POM, and without it no routes are registered. |
| Frontend | A Next UI (`qqq-frontend-next`) release that renders the `SQL_WORKBENCH` widget: `v1.0.0-RC.13` or later. Other frontends show the app and tables but not the notebook. |
| Registration | `new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance)` after your backends are in the instance. The producer registers its own QBit metadata; don't add it by hand. To nest the app (for example under Admin), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing. |
-| Storage | Create the five tables on `backendName` before first use ([Storage](#storage)). PostgreSQL DDL ships in [docs/ddl/postgres.sql](docs/ddl/postgres.sql) and drops into a Liquibase formatted-SQL changeset; translate it for other databases. Match the snake_case names with a `tableMetaDataCustomizer`. |
+| Storage | Create the six tables on `backendName` before first use ([Storage](#storage)). From RC.1, run the share-table DDL and its backfill ([upgrade](#storage-upgrade-from-100-rc1)). PostgreSQL DDL ships in [docs/ddl/postgres.sql](docs/ddl/postgres.sql) and drops into a Liquibase formatted-SQL changeset; translate it for other databases. Match the snake_case names with a `tableMetaDataCustomizer`. |
| Targets | Every RDBMS backend in the instance is a target unless you narrow it with `allowedBackendNames` or `excludedBackendNames`. |
| Secrets | In production, configure `readOnlyCredentials` for each target backend with a SELECT-only database role, and optionally `writeCredentials`. Pass them as `${env.X}` references (the example uses `SQLWB_RO_USER` and `SQLWB_RO_PASSWORD`; the names are yours). The qbit reads no other environment variables. |
-| Permissions | `sqlWorkbench.hasAccess` (use), `sqlWorkbench.write` (write mode), `sqlWorkbench.share` (shared library), `sqlWorkbenchAdmin.hasAccess` (raw table access to every user's notebooks and the full audit; administrators and auditors only). QQQ 4.1 does not list `write` and `share` as available permissions, so grant them where you grant roles. See [Permissions](#permissions). |
+| Permissions | Grant `config.getUserPermissionNames()` to workbench users, plus `getWritePermissionName()` for write mode, `getSharePermissionNames()` for sharing, and `getAdminPermissionNames()` for administrators and auditors only. See [Permissions](#permissions). |
+| Security keys | Call `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` in your authentication customizer after setting permissions. Pass the user's groups if you want group shares. |
+| Navigation | The app lists the widget and the six tables. Its processes are record actions on the tables; keep them out of the nav. If your app adds explicit table menus or nav-coverage tests, include the six tables. |
| Turning writes off | `withWriteModeEnabled(false)` disables write mode for everyone. |
-| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. The qbit produces no processes. |
-| Upgrading | 1.0 is the first release; there is nothing to migrate. |
+| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. Shares are read-only (no shared editing). |
+| Upgrading | From RC.1: run the share-table DDL and backfill, grant the new permission lists (the tables no longer rely on `sqlWorkbenchAdmin.hasAccess` alone), call `applyToSession`, and use QQQ `4.1.0-RC.4` or later. |
Nothing in the qbit is specific to one host application.
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 1552f69..85ecfc0 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -64,7 +64,8 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|---|---|
| `SqlWorkbenchQBitConfig` | Host-facing config (§7). Implements `QBitConfig` with list-based, null-safe `validate`, `getDefaultBackendNameForTables`, and `getTableMetaDataCustomizer`. |
| `SqlWorkbenchQBitProducer` | `QBitMetaDataProducer`, the same pattern as Quick Search. The default `produce()` registers `QBitMetaData`, runs the component producers in `metadata/`, and stamps `sourceQBitName`. `postProduceActions` builds the `SqlWorkbenchRuntime` and registers the Javalin route provider (when Javalin is on the classpath). `produce(QInstance)` adds everything to the instance (Quick Search compat style). |
-| `metadata/*` | The 5 tables, the `sqlWorkbenchWidget` widget (type `SQL_WORKBENCH`), and the `sqlWorkbench` app (widget plus notebook and audit tables). No processes (owner rule). |
+| `metadata/*` | The 6 tables with their record locks, joins and child-list widgets, the two security key types, the record actions (Share Notebook, Duplicate Notebook, Restore Version: processes on tables, never in the nav), the `sqlWorkbenchWidget` widget (type `SQL_WORKBENCH`), and the `sqlWorkbench` app (widget plus all six tables). |
+| `customizers/*` | Table customizers that keep the notebook invariants for every entry point (notebook UI, table UI, `/qqq/v1` API): ownership, cell order and limits, versions, share keys, library visibility, append-only versions and audit, cascades. |
| `SqlWorkbenchRuntime` | Instance-scoped live state, held transient on the config: result cache, running-statement registry, write-preview tokens, schema cache. |
| `sql/*` | `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split plus classify), `NamedParameterSql`. Pure Java, with no JDBC dependency. |
| `engine/*` | `WorkbenchBackends` (target resolution and connection sourcing), `SqlExecutionEngine` (read-only, preview, commit), `SessionGuard` (per-vendor read-only, timeouts, cleanup), `ResultCollector` (typing and limits), `ResultCache`, `WritePreviewTokens`, `ExecutionAuditor`. |
@@ -104,7 +105,18 @@ Components (`src/components/sql-workbench/`):
## 3. Data model
-Five tables on the host-chosen backend (`config.backendName`). Names can be prefixed (`tableNamePrefix`) or customized (`tableMetaDataCustomizer`, for example snake_case backend names). The Postgres DDL is in [`docs/ddl/postgres.sql`](ddl/postgres.sql). Liquibase users can drop it into a formatted-SQL changeset.
+Six tables on the host-chosen backend (`config.backendName`). They are standard QQQ tables in the `sqlWorkbench` app, with the usual query, record, edit, export, audit, and developer-mode screens, and record security locks decide who sees which rows (section 6).
+
+```mermaid
+erDiagram
+ sqlWorkbenchNotebook ||--o{ sqlWorkbenchCell : "cells (ordered)"
+ sqlWorkbenchNotebook ||--o{ sqlWorkbenchNotebookVersion : "versions (append-only)"
+ sqlWorkbenchNotebook ||--o{ sqlWorkbenchResultSnapshot : "pinned results"
+ sqlWorkbenchNotebook ||--o{ sqlWorkbenchNotebookShare : "shared with (read)"
+ sqlWorkbenchExecution }o..o| sqlWorkbenchNotebook : "ran from (no FK)"
+```
+
+ Names can be prefixed (`tableNamePrefix`) or customized (`tableMetaDataCustomizer`, for example snake_case backend names). The Postgres DDL is in [`docs/ddl/postgres.sql`](ddl/postgres.sql). Liquibase users can drop it into a formatted-SQL changeset.
### `sqlWorkbenchNotebook`
| field | type | notes |
@@ -114,10 +126,10 @@ Five tables on the host-chosen backend (`config.backendName`). Names can be pref
| description | TEXT | |
| ownerUserId | STRING(255) | `QSession.user.idReference`, required |
| ownerName | STRING(255) | display only |
-| visibility | STRING(16) | `PERSONAL` (default) or `SHARED` |
+| visibility | STRING(16) | `PERSONAL` or `SHARED`; derived from the shares (`SHARED` while an `ALL` share exists), not editable |
| defaultBackendName | STRING(100) | |
| parametersJson | TEXT | `[{"name","type","defaultValue","label"}]` |
-| currentVersion | INTEGER | incremented on every save |
+| currentVersion | INTEGER | incremented on every content change, from the notebook UI or the tables |
| createDate, modifyDate | DATE_TIME | |
### `sqlWorkbenchCell`
@@ -126,7 +138,7 @@ Five tables on the host-chosen backend (`config.backendName`). Names can be pref
| id | INTEGER PK | |
| notebookId | INTEGER | FK, PVS to notebook |
| cellKey | STRING(64) | client-stable UUID; snapshots and audit refer to it |
-| sortOrder | INTEGER | |
+| sortOrder | INTEGER | cells added through the table go last |
| cellType | STRING(16) | `SQL` or `MARKDOWN` |
| title | STRING(255) | |
| content | TEXT | SQL or markdown |
@@ -134,13 +146,27 @@ Five tables on the host-chosen backend (`config.backendName`). Names can be pref
| createDate, modifyDate | DATE_TIME | |
### `sqlWorkbenchNotebookVersion`
-Append-only history. Each save writes the full notebook plus cells as JSON.
+Append-only history. Each save writes the full notebook plus cells as JSON: a notebook-UI save writes one version; a table edit of the notebook or one of its cells writes one version per change (`NotebookVersioning`, as the system user, after QQQ has authorized the change).
`id, notebookId, versionNumber, contentJson (TEXT), savedByUserId, savedByName, comment, createDate`. Unique key: `(notebookId, versionNumber)`. Retention: `notebookVersionRetention` (default 100 per notebook, oldest pruned on save).
### `sqlWorkbenchResultSnapshot`
-Pinned results.
+Pinned results. Created only by pinning a cached result in the notebook UI (so the data comes from a real run); through the table only `label` can change, and the owner can delete them.
`id, notebookId, cellKey, executionUuid, label, backendName, sqlText (TEXT), resultJson (TEXT: columns plus up to snapshotMaxRows rows), rowCount, truncated, createdByUserId, createDate`.
+### `sqlWorkbenchNotebookShare`
+Who, besides the owner, may read a notebook (read only; no shared editing).
+| field | type | notes |
+|---|---|---|
+| id | INTEGER PK | |
+| notebookId | INTEGER | FK, PVS to notebook; cannot change |
+| audienceType | STRING(16) | `USER`, `GROUP`, or `ALL` (the shared library) |
+| audienceId | STRING(255) | the user id or group name; null for `ALL` |
+| audienceKey | STRING(300) | computed: `user:`, `group:` (lower-cased), or `all`; unique per notebook; what the share locks match |
+| createdByUserId, createdByName | STRING(255) | |
+| createDate, modifyDate | DATE_TIME | |
+
+Publishing in the notebook UI inserts (or deletes) the `ALL` share; the share customizer keeps the notebook's `visibility` in step.
+
### `sqlWorkbenchExecution` (audit, append-only)
| field | type | notes |
|---|---|---|
@@ -164,7 +190,7 @@ Pinned results.
| clientIp | STRING(64) | from the request, when available |
| createDate | DATE_TIME | |
-The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API cannot change it. The engine writes it through QQQ actions with a system session context. The row is inserted **before** execution (`RUNNING`). If that insert fails, the SQL **does not run** (fail closed). The row is updated at the end. Retention: `auditRetentionDays` (default unlimited; the host prunes).
+The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API do not offer those operations, and its customizer rejects them in the core actions for every session but the system user (admins included). Its record lock shows each user their own rows (admins all). The engine writes it through QQQ actions with a system session context. The row is inserted **before** execution (`RUNNING`). If that insert fails, the SQL **does not run** (fail closed). The row is updated at the end. Retention: `auditRetentionDays` (default unlimited; the host prunes).
---
@@ -332,7 +358,7 @@ Visibility rules:
| Concern | Control |
|---|---|
-| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app and widget permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Publishing to the library needs `sqlWorkbench.share`. The qbit's tables use a separate base name, `sqlWorkbenchAdmin` (`sqlWorkbenchAdmin.hasAccess`), so only administrators reach them through the generic QQQ table UI / API. The names are configurable. |
+| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app and widget permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Sharing (including publishing to the library) needs `sqlWorkbench.share` plus insert/edit/delete on the share table. The tables use QQQ's per-table `READ_INSERT_EDIT_DELETE_PERMISSIONS`; `SqlWorkbenchQBitConfig.getUserPermissionNames / getSharePermissionNames / getAdminPermissionNames` list what to grant per role. `sqlWorkbenchAdmin.hasAccess` grants the all-access key. The names are configurable. |
| What can run | Read-only transactions by default (§4.2); classifier as defense in depth; `FORBIDDEN` statements in every mode; one statement per read-only run. |
| Writes | Separate permission, two-step preview then commit with a server-side single-use token bound to the user, SQL, and backend, a dry run with rollback, a row guard with acknowledgement, a hard cap, a drift check, and a statement timeout. |
| Credentials | Never returned or logged. Backend metadata goes out only as `{name, label, vendor, dialect}`. `readOnlyCredentials` support `${env.X}` / `${prop.X}` interpolation (`QMetaDataVariableInterpreter`), and the config's `toString` redacts them. |
@@ -340,7 +366,9 @@ Visibility rules:
| Session and CSRF | QQQ middleware session handling. The cookie session is `HttpOnly`; there is no extra CSRF token, matching the rest of `/qqq/v1` (the JSON-only POSTs need `Content-Type: application/json`, which a cross-site form cannot send without a CORS preflight). The route provider rejects non-JSON POST bodies. |
| Isolation between users | Result cache, cancellation, history, and write tokens are keyed by the user's `idReference`. Another user's execution UUID returns 404. |
| QQQ semantics | **Raw SQL bypasses QQQ**: no record security locks, table/field permissions, hidden fields, audit of record changes, or customizers/automations. Rows changed in write mode do **not** trigger QQQ audits, record-change listeners, ESB events, or Quick Search indexing. This is documented in the UI (a banner in write mode) and in the README. Use QQQ processes for business changes. |
-| Personal vs shared | A personal notebook is visible only to its owner. The five tables need `sqlWorkbenchAdmin.hasAccess`, which ordinary workbench users do not hold, so `/qqq/v1/table/...` cannot read or write other users' notebooks, cells, versions, pinned snapshots, or audit rows. `NotebookService` and the history endpoint check owner / visibility / share permission in code, then read and write as the system user (`SystemUser`), as `ExecutionAuditor` does for the audit. Cells, versions, snapshots, and the audit have insert / edit / delete disabled; only the qbit writes them. Holders of the admin permission see every user's notebooks and the full audit. |
+| Which rows | Record security locks, enforced by QQQ's core actions and therefore for every entry point (table UI, `/qqq/v1` table API, exports, PVS lookups, processes, the workbench endpoints). Two key types: `sqlWorkbenchUser` (the session's user id; all-access key `sqlWorkbenchAllAccess` for admins) and `sqlWorkbenchAudience` (`user:`, `group:` per group, `all`). A notebook's lock is OR(owner lock on `ownerUserId`, read-scoped share lock on `sqlWorkbenchNotebookShare.audienceKey` through the share-to-notebook join); its cells, versions and pinned results lock through their notebook (one and two joins); a share row is readable by the notebook's owner or the audience it names; an execution by its `userId`. Writes evaluate only the write-scoped locks, so being shared with is read-only. The RDBMS module de-duplicates rows that match through several shares (`SELECT DISTINCT`, `COUNT(DISTINCT)`). Hosts call `SqlWorkbenchSecurity.applyToSession` from their authentication customizer; sessions without the access permission get no keys and see no rows. |
+| Invariants | Table customizers, shared by the notebook UI and the tables: the owner is the inserting session (only the system user may insert for someone else); `visibility`, `currentVersion` and dates are system-managed; cells are validated (type, length, key) and appended in order, never move notebooks, and every change writes a version; shares compute `audienceKey`, need the share permission, and keep `visibility` in step; pinned results are only created by pinning and only relabeled; versions and the audit are append-only for everyone but the system user. `NotebookService` runs as the caller through the same actions (flagging a whole-notebook save so it is versioned once) and keeps its explicit owner / share checks as defense in depth; only version bookkeeping and cascades run as the system user, after QQQ authorized the change. |
+| QQQ dependency | Partial updates of child rows (a cell's content, a pinned result's label) pass the joined write locks only with QQQ `4.1.0-RC.4`+ (qqq#1073: locks are validated against the stored row overlaid with the written values). |
---
@@ -360,7 +388,7 @@ Visibility rules:
| `accessPermissionBaseName` | `sqlWorkbench` | `.hasAccess` |
| `writePermissionName` | `sqlWorkbench.write` | |
| `sharePermissionName` | `sqlWorkbench.share` | |
-| `adminPermissionBaseName` | `sqlWorkbenchAdmin` | `.hasAccess` guards the qbit's tables in the generic table UI / API; must differ from `accessPermissionBaseName` |
+| `adminPermissionBaseName` | `sqlWorkbenchAdmin` | `.hasAccess` grants the all-access security key (every user's rows); must differ from `accessPermissionBaseName` |
| `statementTimeoutSeconds` | 30 | read-only and EXPLAIN |
| `writeStatementTimeoutSeconds` | 60 | preview and commit |
| `maxRows` | 10000 | rows kept per result |
diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql
index 79dec46..a1a5aca 100644
--- a/docs/ddl/postgres.sql
+++ b/docs/ddl/postgres.sql
@@ -75,6 +75,33 @@ CREATE TABLE IF NOT EXISTS sql_workbench_result_snapshot (
CONSTRAINT fk_sql_workbench_result_snapshot_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
);
+-- Shares (1.0.0-RC.2): who, besides the owner, may read a notebook.
+-- audience_key is "user:", "group:" or "all" (the shared library);
+-- the record security locks match it against the session's audience keys.
+CREATE TABLE IF NOT EXISTS sql_workbench_notebook_share (
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ notebook_id INTEGER NOT NULL,
+ audience_type VARCHAR(16) NOT NULL,
+ audience_id VARCHAR(255),
+ audience_key VARCHAR(300) NOT NULL,
+ created_by_user_id VARCHAR(255),
+ created_by_name VARCHAR(255),
+ create_date TIMESTAMP WITHOUT TIME ZONE,
+ modify_date TIMESTAMP WITHOUT TIME ZONE,
+ CONSTRAINT pk_sql_workbench_notebook_share PRIMARY KEY (id),
+ CONSTRAINT uq_sql_workbench_notebook_share UNIQUE (notebook_id, audience_key),
+ CONSTRAINT fk_sql_workbench_notebook_share_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
+);
+CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_share_audience ON sql_workbench_notebook_share (audience_key, notebook_id);
+
+-- Upgrading from 1.0.0-RC.1: notebooks published to the library
+-- (visibility SHARED) get their "all" share row. Idempotent.
+INSERT INTO sql_workbench_notebook_share (notebook_id, audience_type, audience_id, audience_key, created_by_user_id, created_by_name, create_date, modify_date)
+SELECT n.id, 'ALL', NULL, 'all', n.owner_user_id, n.owner_name, COALESCE(n.modify_date, CURRENT_TIMESTAMP), COALESCE(n.modify_date, CURRENT_TIMESTAMP)
+FROM sql_workbench_notebook n
+WHERE n.visibility = 'SHARED'
+ AND NOT EXISTS (SELECT 1 FROM sql_workbench_notebook_share s WHERE s.notebook_id = n.id AND s.audience_key = 'all');
+
-- Audit: append-only. Not foreign-keyed to notebooks, so deleting a
-- notebook never deletes its audit trail.
CREATE TABLE IF NOT EXISTS sql_workbench_execution (
diff --git a/pom.xml b/pom.xml
index 807d0d6..1315520 100644
--- a/pom.xml
+++ b/pom.xml
@@ -251,7 +251,7 @@
-
+
qqq-snapshot
@@ -259,7 +259,7 @@
true
- 4.1.0-RC.2
+ 4.1.0-RC.4
diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchPermissionChecker.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchPermissionChecker.java
new file mode 100644
index 0000000..06a108c
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchPermissionChecker.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.kingsrook.qbits.sqlworkbench;
+
+
+import java.util.Map;
+import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
+import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
+import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
+import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
+import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+
+
+/*******************************************************************************
+ ** Gates a record action on one named session permission (for example the
+ ** share table's "insert" permission), and lists that same name among the
+ ** instance's available permissions. Unlike QQQ's
+ ** UseTablePermissionCustomPermissionChecker it needs no QContext instance
+ ** when permissions are listed.
+ *******************************************************************************/
+public class SqlWorkbenchPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
+{
+ public static final String PERMISSION_PROPERTY = "permissionName";
+
+ private String permissionName;
+
+
+
+ /*******************************************************************************
+ ** A code reference that requires the given permission.
+ *******************************************************************************/
+ public static QCodeReference requiring(String permissionName)
+ {
+ return (new QCodeReferenceWithProperties(SqlWorkbenchPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName)));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public void initialize(QCodeReference codeReference)
+ {
+ if(codeReference instanceof QCodeReferenceWithProperties withProperties)
+ {
+ this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Deny unless the session holds the permission (fail closed when it is
+ ** not configured).
+ *******************************************************************************/
+ @Override
+ public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
+ {
+ QSession session = QContext.getQSession();
+ if(permissionName == null || session == null || !session.hasPermission(permissionName))
+ {
+ throw (new QPermissionDeniedException("Permission denied."));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public boolean handlesBuildAvailablePermission()
+ {
+ return (true);
+ }
+
+
+
+ /*******************************************************************************
+ ** The permission is already listed for its table; nothing extra to list.
+ *******************************************************************************/
+ @Override
+ public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
+ {
+ return (null);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitConfig.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitConfig.java
index b0aae69..bf87649 100644
--- a/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitConfig.java
@@ -42,8 +42,12 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
** readOnlyCredentials / writeCredentials per backend, and
** useBackendReadOnlyReplica. Permissions: accessPermissionBaseName
** (=> "sqlWorkbench.hasAccess"), writePermissionName, sharePermissionName,
- ** and adminPermissionBaseName (=> "sqlWorkbenchAdmin.hasAccess"), which
- ** alone grants the generic QQQ table UI / API over the qbit's tables.
+ ** and adminPermissionBaseName (=> "sqlWorkbenchAdmin.hasAccess", which
+ ** grants the all-access security key: every user's notebooks and the whole
+ ** audit). The tables use QQQ's per-table read / insert / edit / delete
+ ** permissions; getUserPermissionNames, getSharePermissionNames and
+ ** getAdminPermissionNames list what to grant per role, and
+ ** SqlWorkbenchSecurity.applyToSession puts the security keys in a session.
** Limits: timeouts, rows, bytes, statement counts, row guard, caches.
**
** validate() is list-based and never throws; the producer fails production
@@ -262,12 +266,8 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
/*******************************************************************************
- ** Permission rules for the qbit's tables: has-access on the admin base
- ** name. Ordinary workbench users do not hold it, so the generic QQQ table
- ** UI / API (/qqq/v1/table/...) cannot read other users' notebooks,
- ** snapshots or the execution audit; the workbench's own endpoints read
- ** and write these tables as the system user after their own owner /
- ** visibility checks.
+ ** Permission rules for the admin-only surface: has-access on the admin
+ ** base name.
*******************************************************************************/
@JsonIgnore
public QPermissionRules getAdminPermissionRules()
@@ -277,6 +277,123 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
+ /*******************************************************************************
+ ** Permission rules for the qbit's tables: QQQ's standard per-table
+ ** read / insert / edit / delete permissions (".read", ...).
+ ** Which rows a user sees and may change is decided by the tables' record
+ ** security locks (owner, shares, admin all-access), not by these.
+ *******************************************************************************/
+ @JsonIgnore
+ public QPermissionRules getTablePermissionRules()
+ {
+ return (new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
+ }
+
+
+
+ /*******************************************************************************
+ ** Security key type whose session value is the user's id; the owner
+ ** locks match it.
+ *******************************************************************************/
+ @JsonIgnore
+ public String getUserSecurityKeyTypeName()
+ {
+ return (applyPrefix("sqlWorkbenchUser"));
+ }
+
+
+
+ /*******************************************************************************
+ ** Security key type whose session values are the user's audience keys
+ ** ("user:", "group:", "all"); the share locks match it.
+ *******************************************************************************/
+ @JsonIgnore
+ public String getAudienceSecurityKeyTypeName()
+ {
+ return (applyPrefix("sqlWorkbenchAudience"));
+ }
+
+
+
+ /*******************************************************************************
+ ** All-access key of the user key type: true for workbench admins.
+ *******************************************************************************/
+ @JsonIgnore
+ public String getAllAccessSecurityKeyName()
+ {
+ return (applyPrefix("sqlWorkbenchAllAccess"));
+ }
+
+
+
+ /*******************************************************************************
+ ** Every permission an ordinary workbench user needs: the access
+ ** permission, read / insert / edit / delete on notebooks and cells, read
+ ** on versions, shares and the execution audit, and read / edit (label) /
+ ** delete on pinned snapshots. Record locks narrow every one of these to
+ ** the user's own (or shared-with-them, for reads) rows.
+ *******************************************************************************/
+ @JsonIgnore
+ public List getUserPermissionNames()
+ {
+ List names = new ArrayList<>();
+ names.add(getAccessPermissionName());
+ addTablePermissions(names, "sqlWorkbenchNotebook", "read", "insert", "edit", "delete");
+ addTablePermissions(names, "sqlWorkbenchCell", "read", "insert", "edit", "delete");
+ addTablePermissions(names, "sqlWorkbenchNotebookVersion", "read");
+ addTablePermissions(names, "sqlWorkbenchResultSnapshot", "read", "edit", "delete");
+ addTablePermissions(names, "sqlWorkbenchNotebookShare", "read");
+ addTablePermissions(names, "sqlWorkbenchExecution", "read");
+ return (names);
+ }
+
+
+
+ /*******************************************************************************
+ ** Permissions to share one's notebooks (with users, groups, or the shared
+ ** library): the share permission plus insert / edit / delete on the share
+ ** table.
+ *******************************************************************************/
+ @JsonIgnore
+ public List getSharePermissionNames()
+ {
+ List names = new ArrayList<>();
+ names.add(sharePermissionName);
+ addTablePermissions(names, "sqlWorkbenchNotebookShare", "insert", "edit", "delete");
+ return (names);
+ }
+
+
+
+ /*******************************************************************************
+ ** Permissions for a workbench admin: everything a user has plus the admin
+ ** permission, which (through SqlWorkbenchSecurity.applyToSession) puts the
+ ** all-access key in the session - every user's notebooks and the whole
+ ** execution audit. Versions and the audit stay append-only for admins.
+ *******************************************************************************/
+ @JsonIgnore
+ public List getAdminPermissionNames()
+ {
+ List names = new ArrayList<>(getUserPermissionNames());
+ names.add(getAdminPermissionName());
+ return (names);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void addTablePermissions(List names, String table, String... suffixes)
+ {
+ for(String suffix : suffixes)
+ {
+ names.add(applyPrefix(table) + "." + suffix);
+ }
+ }
+
+
+
/*******************************************************************************
** QBitConfig: tables without a backend get this one.
*******************************************************************************/
diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchSecurity.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchSecurity.java
new file mode 100644
index 0000000..6ae3c72
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchSecurity.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.kingsrook.qbits.sqlworkbench;
+
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+
+
+/*******************************************************************************
+ ** The workbench's record-security keys.
+ **
+ ** Two security key types guard the qbit's tables:
+ ** - sqlWorkbenchUser: the session's user id. Owner locks (a notebook's
+ ** ownerUserId, an execution's userId) match it. Its all-access key,
+ ** sqlWorkbenchAllAccess, is set for workbench admins.
+ ** - sqlWorkbenchAudience: the session's audience keys: "user:",
+ ** "group:" per group, and "all". Share locks (a share row's
+ ** audienceKey) match it, for reads only.
+ **
+ ** Hosts call applyToSession from their authentication customizer, after
+ ** the session's user and permissions are set, passing the user's groups
+ ** (if the app has any). Without the keys the locks deny: the tables then
+ ** show a user nothing, never someone else's rows.
+ *******************************************************************************/
+public final class SqlWorkbenchSecurity
+{
+ public static final String AUDIENCE_ALL = ShareAudienceType.ALL.audienceKey(null);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private SqlWorkbenchSecurity()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** The two security key types, for the producer.
+ *******************************************************************************/
+ public static List buildSecurityKeyTypes(SqlWorkbenchQBitConfig config)
+ {
+ return (List.of(
+ new QSecurityKeyType().withName(config.getUserSecurityKeyTypeName()).withAllAccessKeyName(config.getAllAccessSecurityKeyName()),
+ new QSecurityKeyType().withName(config.getAudienceSecurityKeyTypeName())));
+ }
+
+
+
+ /*******************************************************************************
+ ** Put the workbench security keys into a session, using the installed
+ ** workbench's configuration. Idempotent: earlier workbench keys are
+ ** replaced.
+ *******************************************************************************/
+ public static void applyToSession(QInstance qInstance, QSession session, Collection groups) throws QNotFoundException
+ {
+ applyToSession(SqlWorkbenchRuntime.get(qInstance).getConfig(), session, groups);
+ }
+
+
+
+ /*******************************************************************************
+ ** Put the workbench security keys into a session: the user id, the
+ ** audience keys (user, each group, all) and, for holders of the admin
+ ** permission, the all-access key. A session without a user id or
+ ** without the workbench access permission gets none, so the locks deny.
+ ** Idempotent.
+ *******************************************************************************/
+ public static void applyToSession(SqlWorkbenchQBitConfig config, QSession session, Collection groups)
+ {
+ if(session == null || session instanceof QSystemUserSession)
+ {
+ return;
+ }
+
+ Map> keys = session.getSecurityKeyValues();
+ if(keys != null)
+ {
+ keys.remove(config.getUserSecurityKeyTypeName());
+ keys.remove(config.getAudienceSecurityKeyTypeName());
+ keys.remove(config.getAllAccessSecurityKeyName());
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // no workbench access, no keys: even a code path that skips table //
+ // permissions then finds no workbench rows (not even the library) //
+ //////////////////////////////////////////////////////////////////////
+ String userId = userId(session);
+ if(!StringUtils.hasContent(userId) || !session.hasPermission(config.getAccessPermissionName()))
+ {
+ return;
+ }
+
+ session.withSecurityKeyValue(config.getUserSecurityKeyTypeName(), userId);
+ for(String audienceKey : audienceKeys(userId, groups))
+ {
+ session.withSecurityKeyValue(config.getAudienceSecurityKeyTypeName(), audienceKey);
+ }
+ if(session.hasPermission(config.getAdminPermissionName()))
+ {
+ session.withSecurityKeyValue(config.getAllAccessSecurityKeyName(), true);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Add the keys only when the session has none yet (keeping the groups a
+ ** host already applied). The workbench endpoints call this, so a host
+ ** that never calls applyToSession still gets owner and library access
+ ** there; group shares need the host to pass the groups.
+ *******************************************************************************/
+ public static void ensureSessionKeys(SqlWorkbenchQBitConfig config, QSession session)
+ {
+ if(session != null && session.getSecurityKeyValues(config.getUserSecurityKeyTypeName()).isEmpty())
+ {
+ applyToSession(config, session, List.of());
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** The audience keys of a user in some groups: "user:", one
+ ** "group:" per group, and "all".
+ *******************************************************************************/
+ public static List audienceKeys(String userId, Collection groups)
+ {
+ Set keys = new LinkedHashSet<>();
+ if(StringUtils.hasContent(userId))
+ {
+ keys.add(ShareAudienceType.USER.audienceKey(userId));
+ }
+ if(groups != null)
+ {
+ for(String group : groups)
+ {
+ if(StringUtils.hasContent(group))
+ {
+ keys.add(ShareAudienceType.GROUP.audienceKey(group));
+ }
+ }
+ }
+ keys.add(AUDIENCE_ALL);
+ return (new ArrayList<>(keys));
+ }
+
+
+
+ /*******************************************************************************
+ ** The current session's audience keys, as applied.
+ *******************************************************************************/
+ public static List sessionAudienceKeys(SqlWorkbenchQBitConfig config)
+ {
+ List keys = new ArrayList<>();
+ QSession session = QContext.getQSession();
+ if(session != null)
+ {
+ for(Serializable value : session.getSecurityKeyValues(config.getAudienceSecurityKeyTypeName()))
+ {
+ keys.add(String.valueOf(value));
+ }
+ }
+ return (keys);
+ }
+
+
+
+ /*******************************************************************************
+ ** True for the system user and for sessions holding the all-access key.
+ *******************************************************************************/
+ public static boolean hasAllAccess(SqlWorkbenchQBitConfig config)
+ {
+ QSession session = QContext.getQSession();
+ return (session instanceof QSystemUserSession || (session != null && session.hasSecurityKeyValue(config.getAllAccessSecurityKeyName(), true, QFieldType.BOOLEAN)));
+ }
+
+
+
+ /*******************************************************************************
+ ** True when the current session is the system user.
+ *******************************************************************************/
+ public static boolean isSystemUser()
+ {
+ return (QContext.getQSession() instanceof QSystemUserSession);
+ }
+
+
+
+ /*******************************************************************************
+ ** The session's user id (the user's id reference, else the session's).
+ *******************************************************************************/
+ public static String userId(QSession session)
+ {
+ if(session == null)
+ {
+ return (null);
+ }
+ if(session.getUser() != null && StringUtils.hasContent(session.getUser().getIdReference()))
+ {
+ return (session.getUser().getIdReference());
+ }
+ return (session.getIdReference());
+ }
+
+
+
+ /*******************************************************************************
+ ** The session's user name (full name, else the id).
+ *******************************************************************************/
+ public static String userName(QSession session)
+ {
+ if(session != null && session.getUser() != null && StringUtils.hasContent(session.getUser().getFullName()))
+ {
+ return (session.getUser().getFullName());
+ }
+ return (userId(session));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/api/SqlWorkbenchApi.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/api/SqlWorkbenchApi.java
index 7b9948e..ef6761e 100644
--- a/src/main/java/com/kingsrook/qbits/sqlworkbench/api/SqlWorkbenchApi.java
+++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/api/SqlWorkbenchApi.java
@@ -40,7 +40,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitProducer;
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
-import com.kingsrook.qbits.sqlworkbench.SystemUser;
+import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
@@ -107,16 +107,12 @@ public class SqlWorkbenchApi
throw (new QPermissionDeniedException("Permission denied."));
}
- String userId = session.getUser() != null && StringUtils.hasContent(session.getUser().getIdReference()) ? session.getUser().getIdReference() : session.getIdReference();
- String userName = session.getUser() != null && StringUtils.hasContent(session.getUser().getFullName()) ? session.getUser().getFullName() : userId;
- if(!StringUtils.hasContent(userId))
+ if(!StringUtils.hasContent(SqlWorkbenchSecurity.userId(session)))
{
throw (new QAuthenticationException("The session has no user."));
}
-
- boolean canWrite = Boolean.TRUE.equals(config.getWriteModeEnabled()) && session.hasPermission(config.getWritePermissionName());
- boolean canShare = session.hasPermission(config.getSharePermissionName());
- return (new Caller(userId, userName, canWrite, canShare, clientIp));
+ SqlWorkbenchSecurity.ensureSessionKeys(config, session);
+ return (Caller.fromSession(config, session, clientIp));
}
@@ -264,10 +260,10 @@ public class SqlWorkbenchApi
List