From ea6e9ffec95e53e1e10c1480fc52be8218de44cd Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 05:47:17 -0500 Subject: [PATCH 1/5] docs(readme): link the QQQ adoption checklist Co-Authored-By: Claude Opus 5.5 Signed-off-by: James Maes --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 173e927..a2229c0 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,8 @@ A checklist for adding the workbench to a QQQ application. Each row points to th Nothing in the qbit is specific to one host application. +See also the [QQQ adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) for the full per-feature list (QQQ upgrade, Next dashboard, QBits, migrations, permissions). + ## Build ```bash -- 2.54.0 From ddd85ebe0d14dd091c2d54aa3dbfd82265c92be3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 06:08:10 -0500 Subject: [PATCH 2/5] feat(#3): expose workbench data as standard QQQ tables secured by record locks Notebooks, cells, versions, pinned results, shares and the execution audit are now ordinary QQQ tables in the SQL Workbench app (nav children, record views with child lists, edit, export, audits, developer mode) instead of hidden admin-only tables. - Record security locks (core-enforced, so every entry point): owner OR share audience for notebooks and their children (through joins), the audit by user; admins hold sqlWorkbenchAllAccess. Key types sqlWorkbenchUser / sqlWorkbenchAudience, applied to sessions by SqlWorkbenchSecurity.applyToSession (no keys without workbench access). - New sqlWorkbenchNotebookShare (USER / GROUP / ALL); publishing writes an ALL share and visibility follows the shares. - Per-table read/insert/edit/delete permissions, listed per role by the config (user / share / admin). - Table customizers hold the notebook invariants for the notebook UI and the tables alike: owner from the session, cell validation and order, one version per change, share keys, append-only versions and audit (admins included), pinned results only from pinning, cascades. - NotebookService and history run as the caller through the same actions; explicit owner/share checks stay as defense in depth. - Record actions: Share Notebook, Duplicate Notebook, Restore Version. - DDL: share table plus an idempotent backfill of library shares. - Tests: TableSecurityMatrixTest (role x table x action over the v1 table API, core actions, export, PVS, record actions, notebook endpoints) and a PostgreSQL lock test. Docs: README, DESIGN, CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +- CHANGELOG.md | 22 + CLAUDE.md | 10 +- README.md | 89 +- docs/DESIGN.md | 50 +- docs/ddl/postgres.sql | 27 + pom.xml | 4 +- .../sqlworkbench/SqlWorkbenchQBitConfig.java | 133 ++- .../sqlworkbench/SqlWorkbenchSecurity.java | 250 +++++ .../sqlworkbench/api/SqlWorkbenchApi.java | 22 +- .../AppendOnlyTableCustomizer.java | 90 ++ .../customizers/CellTableCustomizer.java | 259 +++++ .../customizers/NotebookTableCustomizer.java | 276 ++++++ .../customizers/ShareTableCustomizer.java | 284 ++++++ .../customizers/SnapshotTableCustomizer.java | 87 ++ .../customizers/SqlWorkbenchActionFlag.java | 38 + .../customizers/WorkbenchTableCustomizer.java | 265 +++++ .../qbits/sqlworkbench/engine/Caller.java | 16 + .../SqlWorkbenchAppMetaDataProducer.java | 17 +- ...nchNotebookShareTableMetaDataProducer.java | 53 + ...orkbenchRecordActionsMetaDataProducer.java | 139 +++ ...benchSecurityAndJoinsMetaDataProducer.java | 74 ++ .../SqlWorkbenchTableMetaDataHelper.java | 371 ++++++- .../sqlworkbench/model/ShareAudienceType.java | 116 +++ .../model/SqlWorkbenchNotebookShare.java | 363 +++++++ .../notebook/NotebookService.java | 609 ++++++------ .../notebook/NotebookVersioning.java | 185 ++++ .../SqlWorkbenchRecordActionSteps.java | 163 ++++ .../sqlworkbench/BaseSqlWorkbenchTest.java | 34 +- .../sqlworkbench/PostgresIntegrationTest.java | 63 +- .../SqlWorkbenchQBitProducerTest.java | 46 +- .../engine/SqlExecutionEngineTest.java | 3 +- .../metadata/TableAccessTest.java | 177 ---- .../metadata/TableSecurityMatrixTest.java | 910 ++++++++++++++++++ .../notebook/NotebookServiceTest.java | 76 +- src/test/resources/sql-workbench-h2.sql | 16 + 36 files changed, 4686 insertions(+), 661 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchSecurity.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/AppendOnlyTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/CellTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SnapshotTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SqlWorkbenchActionFlag.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchNotebookShareTableMetaDataProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchSecurityAndJoinsMetaDataProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/model/ShareAudienceType.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/model/SqlWorkbenchNotebookShare.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/processes/SqlWorkbenchRecordActionSteps.java delete mode 100644 src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableAccessTest.java create mode 100644 src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java 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..90420fe 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 and pinned results; field-level for 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..deac56b 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); `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. + +Shares get field-level QQQ audits. Notebooks, cells, and pinned results get record-level audits, so their SQL text and result data are not copied into the audit tables. + ## 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..311cad0 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.3 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> executions = new ArrayList<>(); ////////////////////////////////////////////////////////////////////// - // the audit table needs the admin permission; the filter above is // - // the boundary (own rows only), so read it as the system user // + // read as the caller: the audit's record lock limits them to their // + // own rows (admins see all, so the filter keeps history personal) // ////////////////////////////////////////////////////////////////////// - for(QRecord record : SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter))) + for(QRecord record : QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter)) { SqlWorkbenchExecution execution = new SqlWorkbenchExecution(record); Map view = new LinkedHashMap<>(); @@ -307,10 +303,10 @@ public class SqlWorkbenchApi { String executionUuid = normalizeUuid(requireString(body, "executionUuid")); cached(caller, executionUuid); - List audit = SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), + List audit = QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), new QQueryFilter(new QFilterCriteria("executionUuid", QCriteriaOperator.EQUALS, executionUuid)) .withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, caller.userId())) - .withLimit(1))); + .withLimit(1)); String sqlText = audit.isEmpty() ? null : audit.get(0).getValueString("sqlText"); Map normalized = new LinkedHashMap<>(body); diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/AppendOnlyTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/AppendOnlyTableCustomizer.java new file mode 100644 index 0000000..514bfbf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/AppendOnlyTableCustomizer.java @@ -0,0 +1,90 @@ +/* + * 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.customizers; + + +import java.util.List; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Notebook versions and the execution audit are append-only: only the qbit + ** itself (the system user) inserts them, and only its retention pruning + ** deletes them. The tables also lack the insert / update / delete + ** capabilities, which hides those operations in the UI and API; this + ** customizer enforces it in the core actions, whoever calls them (admins + ** included). + *******************************************************************************/ +public class AppendOnlyTableCustomizer extends WorkbenchTableCustomizer +{ + private static final String MESSAGE = "This table is append-only: only the SQL Workbench writes it."; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + return (denyUnlessSystem(records)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + return (denyUnlessSystem(records)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException + { + return (denyUnlessSystem(records)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static List denyUnlessSystem(List records) + { + if(!isSystemUser()) + { + for(QRecord record : records) + { + denied(record, MESSAGE); + } + } + return (records); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/CellTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/CellTableCustomizer.java new file mode 100644 index 0000000..b427a66 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/CellTableCustomizer.java @@ -0,0 +1,259 @@ +/* + * 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.customizers; + + +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Cell invariants, the same for the notebook UI and the table UI / API: + ** type SQL or MARKDOWN, content within the SQL length limit, a stable + ** cellKey, at most NotebookService.MAX_CELLS per notebook, new cells + ** appended at the end, cells never move between notebooks, and every + ** change appends a notebook version. Ownership is the notebook's (the + ** record lock checks it through the notebook). + *******************************************************************************/ +public class CellTableCustomizer extends WorkbenchTableCustomizer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + SqlWorkbenchQBitConfig config = config(); + Instant now = clock().instant(); + boolean wholeNotebook = insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) || insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL); + + Map nextSortOrderAndCount = new HashMap<>(); + for(QRecord record : records) + { + Integer notebookId = record.getValueInteger("notebookId"); + if(notebookId == null) + { + badInput(record, "A cell needs a notebook."); + continue; + } + + validate(config, record, true); + if(!StringUtils.hasContent(record.getValueString("cellKey"))) + { + record.setValue("cellKey", UUID.randomUUID().toString()); + } + record.setValue("createDate", now); + record.setValue("modifyDate", now); + + if(!wholeNotebook) + { + int[] state = nextSortOrderAndCount.get(notebookId); + if(state == null) + { + state = existing(config, notebookId); + nextSortOrderAndCount.put(notebookId, state); + } + if(state[1] >= NotebookService.MAX_CELLS) + { + badInput(record, "A notebook can have at most " + NotebookService.MAX_CELLS + " cells."); + continue; + } + state[1]++; + if(record.getValueInteger("sortOrder") == null) + { + record.setValue("sortOrder", state[0]++); + } + } + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + if(!insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + appendVersions(notebookIds(records), "Cell added", insertInput.getTransaction()); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + if(updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + return (records); + } + + SqlWorkbenchQBitConfig config = config(); + Instant now = clock().instant(); + for(QRecord record : records) + { + if(!ok(record)) + { + continue; + } + QRecord old = oldRecord(oldRecordList, record); + if(changes(record, old, "notebookId")) + { + badInput(record, "A cell cannot move to another notebook."); + continue; + } + if(changes(record, old, "cellKey")) + { + badInput(record, "A cell's key cannot change."); + continue; + } + record.removeValue("createDate"); + validate(config, record, false); + record.setValue("modifyDate", now); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException + { + if(!updateInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + Set ids = new LinkedHashSet<>(); + for(QRecord record : records) + { + QRecord old = oldRecord(oldRecordList, record); + if(ok(record) && old != null && old.getValueInteger("notebookId") != null) + { + ids.add(old.getValueInteger("notebookId")); + } + } + appendVersions(ids, "Cell edited", updateInput.getTransaction()); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postDelete(DeleteInput deleteInput, List records) throws QException + { + if(!deleteInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + appendVersions(notebookIds(records), "Cell removed", deleteInput.getTransaction()); + } + return (records); + } + + + + /******************************************************************************* + ** Type, content and key checks. + *******************************************************************************/ + private static void validate(SqlWorkbenchQBitConfig config, QRecord record, boolean inserting) + { + if(inserting || record.getValues().containsKey("cellType")) + { + String type = record.getValueString("cellType"); + type = StringUtils.hasContent(type) ? type.trim().toUpperCase(Locale.ROOT) : NotebookService.CELL_SQL; + if(!NotebookService.CELL_SQL.equals(type) && !NotebookService.CELL_MARKDOWN.equals(type)) + { + badInput(record, "cellType must be SQL or MARKDOWN."); + return; + } + record.setValue("cellType", type); + } + + if(inserting && record.getValue("content") == null) + { + record.setValue("content", ""); + } + String content = record.getValueString("content"); + if(content != null && content.length() > config.getMaxSqlLength()) + { + badInput(record, "A cell is longer than the limit of " + config.getMaxSqlLength() + " characters."); + return; + } + + String key = record.getValueString("cellKey"); + if(key != null && key.length() > 64) + { + badInput(record, "A cell key is at most 64 characters."); + } + } + + + + /******************************************************************************* + ** {next sort order, current cell count} of a notebook, as the session sees + ** it (a notebook the session cannot read counts as empty; its insert then + ** fails the record lock). + *******************************************************************************/ + private static int[] existing(SqlWorkbenchQBitConfig config, Integer notebookId) throws QException + { + List cells = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchCell.TABLE_NAME)) + .withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)))).getRecords(); + int max = -1; + for(QRecord cell : cells) + { + Integer sortOrder = cell.getValueInteger("sortOrder"); + if(sortOrder != null && sortOrder > max) + { + max = sortOrder; + } + } + return (new int[] { max + 1, cells.size() }); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java new file mode 100644 index 0000000..d82da62 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java @@ -0,0 +1,276 @@ +/* + * 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.customizers; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import com.fasterxml.jackson.core.type.TypeReference; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.SystemUser; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; +import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Notebook invariants, the same for the notebook UI and the table UI / API: + ** - the owner is the creating session's user (only the qbit itself, as the + ** system user, may insert for someone else, e.g. a migration); + ** - visibility is derived from the shares (an ALL share = the library); + ** - currentVersion / createDate are system-managed; a title is required; + ** parameters are a JSON list; + ** - every content change appends a version; + ** - deleting a notebook deletes its cells, versions, pinned results and + ** shares (only after QQQ has authorized the delete). + *******************************************************************************/ +public class NotebookTableCustomizer extends WorkbenchTableCustomizer +{ + private static final List CONTENT_FIELDS = List.of("title", "description", "defaultBackendName", "parametersJson"); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + boolean system = isSystemUser(); + String userId = SqlWorkbenchSecurity.userId(QContext.getQSession()); + String userName = SqlWorkbenchSecurity.userName(QContext.getQSession()); + Instant now = clock().instant(); + + for(QRecord record : records) + { + if(!system) + { + if(!StringUtils.hasContent(userId)) + { + denied(record, "The session has no user."); + continue; + } + record.setValue("ownerUserId", userId); + record.setValue("ownerName", userName); + record.setValue("visibility", NotebookService.VISIBILITY_PERSONAL); + record.setValue("currentVersion", 0); + } + else + { + if(!StringUtils.hasContent(record.getValueString("ownerUserId"))) + { + badInput(record, "A notebook needs an owner."); + } + if(!StringUtils.hasContent(record.getValueString("visibility"))) + { + record.setValue("visibility", NotebookService.VISIBILITY_PERSONAL); + } + if(record.getValue("currentVersion") == null) + { + record.setValue("currentVersion", 0); + } + } + validateContent(record, true); + record.setValue("createDate", now); + record.setValue("modifyDate", now); + } + return (records); + } + + + + /******************************************************************************* + ** A new notebook made through the table gets version 1 (the notebook UI + ** writes its own, with the cells). + *******************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + if(!insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + Set ids = new LinkedHashSet<>(); + for(QRecord record : records) + { + if(ok(record) && record.getValueInteger("id") != null) + { + ids.add(record.getValueInteger("id")); + } + } + appendVersions(ids, "Created", insertInput.getTransaction()); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + if(isSystemUser() || updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + return (records); + } + + Instant now = clock().instant(); + for(QRecord record : records) + { + if(!ok(record)) + { + continue; + } + + QRecord old = oldRecord(oldRecordList, record); + if(changes(record, old, "visibility")) + { + badInput(record, "Visibility follows the notebook's shares: share it with Everyone to publish it to the shared library."); + continue; + } + if(!changes(record, old, "ownerUserId")) + { + record.removeValue("ownerName"); + } + record.removeValue("currentVersion"); + record.removeValue("createDate"); + validateContent(record, false); + record.setValue("modifyDate", now); + } + return (records); + } + + + + /******************************************************************************* + ** A content edit made through the table appends a version. + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException + { + if(updateInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) || updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + return (records); + } + + Set ids = new LinkedHashSet<>(); + for(QRecord record : records) + { + if(ok(record) && record.getValueInteger("id") != null && CONTENT_FIELDS.stream().anyMatch(field -> record.getValues().containsKey(field))) + { + ids.add(record.getValueInteger("id")); + } + } + appendVersions(ids, "Edited", updateInput.getTransaction()); + return (records); + } + + + + /******************************************************************************* + ** Delete the children of the notebooks QQQ authorized for deletion (the + ** foreign keys need them gone first). + *******************************************************************************/ + @Override + public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException + { + if(isPreview || deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL)) + { + return (records); + } + + List ids = new ArrayList<>(); + for(QRecord record : records) + { + if(ok(record) && record.getValue("id") != null) + { + ids.add(record.getValue("id")); + } + } + if(ids.isEmpty()) + { + return (records); + } + + SqlWorkbenchQBitConfig config = config(); + SystemUser.run(() -> + { + for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME)) + { + new DeleteAction().execute(new DeleteInput(config.applyPrefix(child)) + .withFlag(SqlWorkbenchActionFlag.INTERNAL) + .withTransaction(deleteInput.getTransaction()) + .withQueryFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.IN, ids)))); + } + }); + return (records); + } + + + + /******************************************************************************* + ** Title (required on insert, never blank) and parameters (a JSON list). + *******************************************************************************/ + private static void validateContent(QRecord record, boolean inserting) + { + if(inserting || record.getValues().containsKey("title")) + { + String title = record.getValueString("title"); + if(!StringUtils.hasContent(title)) + { + badInput(record, "A notebook needs a title."); + } + else + { + record.setValue("title", title.trim()); + } + } + + String parameters = record.getValueString("parametersJson"); + if(StringUtils.hasContent(parameters)) + { + try + { + JsonUtils.toObject(parameters, new TypeReference>() {}); + } + catch(Exception e) + { + badInput(record, "Parameters must be a JSON list."); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java new file mode 100644 index 0000000..6c288d9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java @@ -0,0 +1,284 @@ +/* + * 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.customizers; + + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.SystemUser; +import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Share invariants, the same for the notebook UI ("publish") and the table + ** UI / API: + ** - changing shares needs the share permission (the record lock then + ** checks the notebook is the caller's own, or the caller is an admin); + ** - USER and GROUP shares name their audience, ALL (the library) does not; + ** audienceKey is computed here and is what the read locks match; + ** - a share never moves to another notebook; + ** - the notebook's visibility follows its shares (SHARED while it has an + ** ALL share). + *******************************************************************************/ +public class ShareTableCustomizer extends WorkbenchTableCustomizer +{ + private static final String NEEDS_PERMISSION = "Sharing notebooks needs the share permission."; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + boolean allowed = mayShare(); + QSession session = QContext.getQSession(); + Instant now = clock().instant(); + for(QRecord record : records) + { + if(!allowed) + { + denied(record, NEEDS_PERMISSION); + continue; + } + if(record.getValueInteger("notebookId") == null) + { + badInput(record, "A share needs a notebook."); + continue; + } + applyAudience(record, record.getValueString("audienceType"), record.getValueString("audienceId")); + if(!isSystemUser() || !StringUtils.hasContent(record.getValueString("createdByUserId"))) + { + record.setValue("createdByUserId", SqlWorkbenchSecurity.userId(session)); + record.setValue("createdByName", SqlWorkbenchSecurity.userName(session)); + } + record.setValue("createDate", now); + record.setValue("modifyDate", now); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + syncVisibility(insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), notebookIds(records), insertInput.getTransaction()); + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + boolean allowed = mayShare(); + Instant now = clock().instant(); + for(QRecord record : records) + { + if(!ok(record)) + { + continue; + } + if(!allowed) + { + denied(record, NEEDS_PERMISSION); + continue; + } + QRecord old = oldRecord(oldRecordList, record); + if(changes(record, old, "notebookId")) + { + badInput(record, "A share cannot move to another notebook."); + continue; + } + record.removeValue("createdByUserId"); + record.removeValue("createdByName"); + record.removeValue("createDate"); + if(record.getValues().containsKey("audienceType") || record.getValues().containsKey("audienceId")) + { + String type = record.getValues().containsKey("audienceType") ? record.getValueString("audienceType") : old == null ? null : old.getValueString("audienceType"); + String id = record.getValues().containsKey("audienceId") ? record.getValueString("audienceId") : old == null ? null : old.getValueString("audienceId"); + applyAudience(record, type, id); + } + else + { + record.removeValue("audienceKey"); + } + record.setValue("modifyDate", now); + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException + { + Set ids = new LinkedHashSet<>(); + for(QRecord record : records) + { + QRecord old = oldRecord(oldRecordList, record); + if(ok(record) && old != null && old.getValueInteger("notebookId") != null) + { + ids.add(old.getValueInteger("notebookId")); + } + } + syncVisibility(updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), ids, updateInput.getTransaction()); + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException + { + if(!mayShare()) + { + for(QRecord record : records) + { + if(ok(record)) + { + denied(record, NEEDS_PERMISSION); + } + } + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List postDelete(DeleteInput deleteInput, List records) throws QException + { + syncVisibility(deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), notebookIds(records), deleteInput.getTransaction()); + return (records); + } + + + + /******************************************************************************* + ** The system user, or a session holding the share permission. + *******************************************************************************/ + private static boolean mayShare() throws QException + { + QSession session = QContext.getQSession(); + return (isSystemUser() || (session != null && session.hasPermission(config().getSharePermissionName()))); + } + + + + /******************************************************************************* + ** Validate and normalize the audience; compute the audience key. + *******************************************************************************/ + private static void applyAudience(QRecord record, String typeValue, String audienceId) + { + ShareAudienceType type = ShareAudienceType.of(typeValue); + if(type == null) + { + badInput(record, "audienceType must be USER, GROUP or ALL."); + return; + } + + record.setValue("audienceType", type.name()); + if(type == ShareAudienceType.ALL) + { + record.setValue("audienceId", null); + } + else if(!StringUtils.hasContent(audienceId)) + { + badInput(record, type == ShareAudienceType.USER ? "Name the user to share with." : "Name the group to share with."); + return; + } + else + { + record.setValue("audienceId", audienceId.trim()); + } + record.setValue("audienceKey", type.audienceKey(audienceId)); + } + + + + /******************************************************************************* + ** Set each notebook's visibility from its shares: SHARED while it has an + ** ALL share, else PERSONAL (system user; the share change was already + ** authorized). + *******************************************************************************/ + private static void syncVisibility(boolean internal, Set notebookIds, QBackendTransaction transaction) throws QException + { + if(internal || notebookIds.isEmpty()) + { + return; + } + + SqlWorkbenchQBitConfig config = config(); + Instant now = clock().instant(); + SystemUser.run(() -> + { + for(Integer notebookId : notebookIds) + { + Integer allShares = new CountAction().execute(new CountInput(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME)) + .withTransaction(transaction) + .withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)) + .withCriteria(new QFilterCriteria("audienceKey", QCriteriaOperator.EQUALS, SqlWorkbenchSecurity.AUDIENCE_ALL)))).getCount(); + String visibility = allShares != null && allShares > 0 ? NotebookService.VISIBILITY_SHARED : NotebookService.VISIBILITY_PERSONAL; + new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.INTERNAL) + .withTransaction(transaction) + .withRecord(new QRecord().withValue("id", notebookId).withValue("visibility", visibility).withValue("modifyDate", now))); + } + }); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SnapshotTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SnapshotTableCustomizer.java new file mode 100644 index 0000000..dfa697d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SnapshotTableCustomizer.java @@ -0,0 +1,87 @@ +/* + * 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.customizers; + + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Pinned results are created only by pinning a cached result from the + ** notebook UI (the data must come from a real run, never from a client); + ** through the table only the label can be edited, and the notebook's owner + ** can delete them (the record lock checks ownership through the notebook). + *******************************************************************************/ +public class SnapshotTableCustomizer extends WorkbenchTableCustomizer +{ + private static final Set EDITABLE = Set.of("id", "label"); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + if(!isSystemUser() && !insertInput.hasFlag(SqlWorkbenchActionFlag.SNAPSHOT_PIN)) + { + for(QRecord record : records) + { + denied(record, "Results are pinned from the notebook: run a cell, then pin its result."); + } + } + return (records); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + if(isSystemUser()) + { + return (records); + } + for(QRecord record : records) + { + if(!ok(record)) + { + continue; + } + QRecord old = oldRecord(oldRecordList, record); + for(String field : List.copyOf(record.getValues().keySet())) + { + if(!EDITABLE.contains(field) && changes(record, old, field)) + { + badInput(record, "Only a pinned result's label can be edited."); + break; + } + } + } + return (records); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SqlWorkbenchActionFlag.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SqlWorkbenchActionFlag.java new file mode 100644 index 0000000..c5780af --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/SqlWorkbenchActionFlag.java @@ -0,0 +1,38 @@ +/* + * 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.customizers; + + +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; + + +/******************************************************************************* + ** Flags the qbit puts on its own table actions, so the customizers know a + ** write is part of a larger, already-validated operation. Flags are set in + ** Java only; no API or UI request can carry them. + *******************************************************************************/ +public enum SqlWorkbenchActionFlag implements ActionFlag +{ + /** a whole-notebook save from NotebookService: it writes one version itself */ + NOTEBOOK_SAVE, + + /** pinning a cached result from the notebook UI (snapshot inserts) */ + SNAPSHOT_PIN, + + /** the qbit's own bookkeeping (version bump, visibility sync, cascades) */ + INTERNAL +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java new file mode 100644 index 0000000..32675de --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java @@ -0,0 +1,265 @@ +/* + * 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.customizers; + + +import java.io.Serializable; +import java.time.Clock; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.notebook.NotebookVersioning; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** Shared plumbing for the workbench table customizers. + ** + ** Pre-insert customizers run before QQQ's validations, so they can stamp + ** the owner / defaults the record locks and required-field checks then + ** verify. Update and delete validations (locks included) run before the + ** pre-update / pre-delete customizers, which skip records that already + ** carry errors and never rewrite a lock field. + *******************************************************************************/ +public abstract class WorkbenchTableCustomizer implements TableCustomizerInterface +{ + + /******************************************************************************* + ** Stamp defaults before QQQ validates (required fields, record locks). + *******************************************************************************/ + @Override + public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview) + { + return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS); + } + + + + /******************************************************************************* + ** Pass-through; subclasses override the hooks they need. + *******************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** Pass-through. + *******************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** Pass-through. + *******************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** Pass-through. + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** Pass-through. + *******************************************************************************/ + @Override + public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** Pass-through. + *******************************************************************************/ + @Override + public List postDelete(DeleteInput deleteInput, List records) throws QException + { + return (records); + } + + + + /******************************************************************************* + ** The old record for a record, by primary key "id". + *******************************************************************************/ + protected static QRecord oldRecord(Optional> oldRecordList, QRecord record) + { + Serializable id = record.getValue("id"); + for(QRecord old : oldRecordList.orElse(List.of())) + { + if(id != null && Objects.equals(String.valueOf(id), String.valueOf(old.getValue("id")))) + { + return (old); + } + } + return (null); + } + + + + /******************************************************************************* + ** The installed workbench's configuration. + *******************************************************************************/ + protected static SqlWorkbenchQBitConfig config() throws QException + { + return (SqlWorkbenchRuntime.get().getConfig()); + } + + + + /******************************************************************************* + ** The workbench clock. + *******************************************************************************/ + protected static Clock clock() throws QException + { + return (SqlWorkbenchRuntime.get().getClock()); + } + + + + /******************************************************************************* + ** True if the record has no errors yet. + *******************************************************************************/ + protected static boolean ok(QRecord record) + { + return (CollectionUtils.nullSafeIsEmpty(record.getErrors())); + } + + + + /******************************************************************************* + ** Add a bad-input error to a record. + *******************************************************************************/ + protected static void badInput(QRecord record, String message) + { + record.addError(new BadInputStatusMessage(message)); + } + + + + /******************************************************************************* + ** Add a permission-denied error to a record. + *******************************************************************************/ + protected static void denied(QRecord record, String message) + { + record.addError(new PermissionDeniedMessage(message)); + } + + + + /******************************************************************************* + ** True if the record sets the field to a value different from the old + ** record's (absent fields are unchanged). + *******************************************************************************/ + protected static boolean changes(QRecord record, QRecord oldRecord, String field) + { + if(!record.getValues().containsKey(field)) + { + return (false); + } + Serializable oldValue = oldRecord == null ? null : oldRecord.getValue(field); + return (!Objects.equals(normalize(record.getValue(field)), normalize(oldValue))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String normalize(Serializable value) + { + return (value == null ? null : value.toString()); + } + + + + /******************************************************************************* + ** The distinct notebook ids of the error-free records. + *******************************************************************************/ + protected static Set notebookIds(List records) + { + Set ids = new LinkedHashSet<>(); + for(QRecord record : CollectionUtils.nonNullList(records)) + { + if(ok(record) && record.getValueInteger("notebookId") != null) + { + ids.add(record.getValueInteger("notebookId")); + } + } + return (ids); + } + + + + /******************************************************************************* + ** Append one version per notebook. + *******************************************************************************/ + protected static void appendVersions(Set notebookIds, String comment, QBackendTransaction transaction) throws QException + { + for(Integer notebookId : notebookIds) + { + NotebookVersioning.appendVersion(config(), clock(), notebookId, comment, transaction); + } + } + + + + /******************************************************************************* + ** True for the qbit's own (system user) writes. + *******************************************************************************/ + protected static boolean isSystemUser() + { + return (SqlWorkbenchSecurity.isSystemUser()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/engine/Caller.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/engine/Caller.java index 7fd76ff..0095a25 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/engine/Caller.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/engine/Caller.java @@ -17,6 +17,10 @@ package com.kingsrook.qbits.sqlworkbench.engine; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qqq.backend.core.model.session.QSession; + /******************************************************************************* ** Who is calling, and what they may do (resolved from the QSession by the ** API layer). @@ -29,4 +33,16 @@ package com.kingsrook.qbits.sqlworkbench.engine; *******************************************************************************/ public record Caller(String userId, String userName, boolean canWrite, boolean canShare, String clientIp) { + + /******************************************************************************* + ** The Caller of a session (which must already be authenticated and hold + ** the access permission - the API and the record actions check that). + *******************************************************************************/ + public static Caller fromSession(SqlWorkbenchQBitConfig config, QSession session, String clientIp) + { + String userId = SqlWorkbenchSecurity.userId(session); + boolean canWrite = Boolean.TRUE.equals(config.getWriteModeEnabled()) && session.hasPermission(config.getWritePermissionName()); + boolean canShare = session.hasPermission(config.getSharePermissionName()); + return (new Caller(userId, SqlWorkbenchSecurity.userName(session), canWrite, canShare, clientIp)); + } } diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchAppMetaDataProducer.java index 3a3dad0..719face 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchAppMetaDataProducer.java @@ -27,21 +27,30 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; /******************************************************************************* ** The sqlWorkbench app: the workbench widget (rendered by the Next UI as the - ** notebook UI), plus the notebook and execution-audit tables. Gated by the - ** access permission, so the app - and so the feature - is only advertised - ** to users who have it. No processes (they would only add left-nav noise). + ** notebook UI), plus every workbench table as a child - notebooks, cells, + ** versions, pinned results, shares and the execution audit - so the data + ** is administered through the standard QQQ table and record screens. Gated + ** by the access permission; each table also needs its own read permission + ** to appear. No processes in the nav (record actions live on the tables). ** Hosts can re-parent it (e.g. under an Admin app); its name stays the same. *******************************************************************************/ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProducer { public static final String APP_NAME = "sqlWorkbench"; + static final List TABLES_IN_NAV = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME, + SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME); + /******************************************************************************* @@ -62,7 +71,7 @@ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProduc MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput(); if(produced != null) { - for(String tableName : new String[] { SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME }) + for(String tableName : TABLES_IN_NAV) { QTableMetaData table = produced.get(QTableMetaData.class, config.applyPrefix(tableName)); if(table != null) diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchNotebookShareTableMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchNotebookShareTableMetaDataProducer.java new file mode 100644 index 0000000..c666f6f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchNotebookShareTableMetaDataProducer.java @@ -0,0 +1,53 @@ +/* + * 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.metadata; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; + + +/******************************************************************************* + ** Produces the sqlWorkbenchNotebookShare table. + *******************************************************************************/ +public class SqlWorkbenchNotebookShareTableMetaDataProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QTableMetaData produce(QInstance qInstance) throws QException + { + return (SqlWorkbenchTableMetaDataHelper.buildNotebookShareTable(getQBitConfig())); + } + + + + /******************************************************************************* + ** tables first, then the PVS, widget and app + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (100); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java new file mode 100644 index 0000000..023b8a8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java @@ -0,0 +1,139 @@ +/* + * 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.metadata; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qbits.sqlworkbench.processes.SqlWorkbenchRecordActionSteps; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; + + +/******************************************************************************* + ** Record actions on the workbench tables (never nav entries): + ** - Share Notebook (notebook): share with a user, a group, or everyone (the + ** shared library); needs share-table insert permission. + ** - Duplicate Notebook (notebook): a personal copy of a readable notebook; + ** needs notebook insert permission. + ** - Restore Version (notebook version): restore it as a new version of the + ** (owned) notebook; needs notebook edit permission. + *******************************************************************************/ +public class SqlWorkbenchRecordActionsMetaDataProducer extends QBitComponentMetaDataProducer +{ + public static final String SHARE_NOTEBOOK = "sqlWorkbenchShareNotebook"; + public static final String DUPLICATE_NOTEBOOK = "sqlWorkbenchDuplicateNotebook"; + public static final String RESTORE_VERSION = "sqlWorkbenchRestoreNotebookVersion"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException + { + SqlWorkbenchQBitConfig config = getQBitConfig(); + String notebook = config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME); + + MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + output.add(recordAction(config.applyPrefix(SHARE_NOTEBOOK), "Share Notebook", "share", notebook, + config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME), TablePermissionSubType.INSERT, SqlWorkbenchRecordActionSteps.ShareNotebookStep.class, + List.of(new QFieldMetaData("audienceType", QFieldType.STRING).withLabel("Share With").withIsRequired(true).withPossibleValueSourceName(config.applyPrefix(ShareAudienceType.NAME)), + new QFieldMetaData("audienceId", QFieldType.STRING).withLabel("User or Group (blank for Everyone)")))); + output.add(recordAction(config.applyPrefix(DUPLICATE_NOTEBOOK), "Duplicate Notebook", "content_copy", notebook, + notebook, TablePermissionSubType.INSERT, SqlWorkbenchRecordActionSteps.DuplicateNotebookStep.class, List.of())); + output.add(recordAction(config.applyPrefix(RESTORE_VERSION), "Restore Version", "restore", config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME), + notebook, TablePermissionSubType.EDIT, SqlWorkbenchRecordActionSteps.RestoreVersionStep.class, List.of())); + return (output); + } + + + + /******************************************************************************* + ** A one-record action: optional input form, the backend step (records + ** loaded through the table's locks), and a result screen. + *******************************************************************************/ + private static QProcessMetaData recordAction(String name, String label, String icon, String tableName, String permissionTable, TablePermissionSubType permission, + Class step, List formFields) + { + List steps = new ArrayList<>(); + if(!formFields.isEmpty()) + { + steps.add(new QFrontendStepMetaData() + .withName("input") + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)) + .withFormFields(formFields)); + } + steps.add(new QBackendStepMetaData() + .withName("execute") + .withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(tableName))) + .withCode(new QCodeReference(step))); + steps.add(new QFrontendStepMetaData() + .withName("result") + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)) + .withViewField(new QFieldMetaData(SqlWorkbenchRecordActionSteps.RESULT_MESSAGE, QFieldType.STRING).withLabel("Result"))); + + return (new QProcessMetaData() + .withName(name) + .withLabel(label) + .withIcon(new QIcon(icon)) + .withTableName(tableName) + .withMinInputRecords(1) + .withMaxInputRecords(1) + .withPermissionRules(new QPermissionRules() + .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(permissionTable, permission))) + .withStepList(steps)); + } + + + + /******************************************************************************* + ** after the tables and their joins + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (160); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchSecurityAndJoinsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchSecurityAndJoinsMetaDataProducer.java new file mode 100644 index 0000000..9e430b7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchSecurityAndJoinsMetaDataProducer.java @@ -0,0 +1,74 @@ +/* + * 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.metadata; + + +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; + + +/******************************************************************************* + ** Produces what the tables' locks and record views need: the two security + ** key types, the share audience-type PVS, the notebook joins, and the + ** child-record list widgets on a notebook's record view. + *******************************************************************************/ +public class SqlWorkbenchSecurityAndJoinsMetaDataProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException + { + SqlWorkbenchQBitConfig config = getQBitConfig(); + MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + for(QSecurityKeyType keyType : SqlWorkbenchSecurity.buildSecurityKeyTypes(config)) + { + output.add(keyType); + } + output.add(SqlWorkbenchTableMetaDataHelper.buildAudienceTypePossibleValueSource(config)); + for(QJoinMetaData join : SqlWorkbenchTableMetaDataHelper.buildJoins(config)) + { + output.add(join); + } + for(QWidgetMetaDataInterface widget : SqlWorkbenchTableMetaDataHelper.buildChildListWidgets(config)) + { + output.add(widget); + } + return (output); + } + + + + /******************************************************************************* + ** after the tables, before the PVS, widget and app + *******************************************************************************/ + @Override + public int getSortOrder() + { + return (150); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java index d8d9f54..d92d0cd 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java @@ -17,50 +17,109 @@ package com.kingsrook.qbits.sqlworkbench.metadata; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; -import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; -import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; -import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import java.util.Map; import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.customizers.AppendOnlyTableCustomizer; +import com.kingsrook.qbits.sqlworkbench.customizers.CellTableCustomizer; +import com.kingsrook.qbits.sqlworkbench.customizers.NotebookTableCustomizer; +import com.kingsrook.qbits.sqlworkbench.customizers.ShareTableCustomizer; +import com.kingsrook.qbits.sqlworkbench.customizers.SnapshotTableCustomizer; +import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; +import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* - ** Builds the QTableMetaData of the five SQL Workbench tables. Shared by the - ** component producers and by tests. + ** Builds the SQL Workbench tables, joins and record-view widgets. Shared by + ** the component producers and by tests. ** - ** All tables are protected by the ADMIN permission - ** (sqlWorkbenchAdmin.hasAccess), not the access permission every workbench - ** user holds: the generic QQQ table UI / API would otherwise let any user - ** read other users' personal notebooks, pinned result data and the full - ** execution audit. The workbench endpoints do their own owner / visibility - ** checks and then read and write these tables as the system user. + ** The tables are ordinary QQQ tables (query, view, edit, export, audits, + ** developer mode), secured by QQQ mechanisms rather than by hiding them: ** - ** Cells, versions, snapshots and the audit are read-only even for admins - ** (insert / edit / delete capabilities disabled); only the qbit writes - ** them. Notebooks stay editable for admins (e.g. to clean up). + ** - Permissions: QQQ's per-table read / insert / edit / delete permissions + ** (SqlWorkbenchQBitConfig lists them per role). + ** - Record security locks, enforced by the core actions and so for every + ** entry point (table UI, /qqq/v1 table API, processes, PVS lookups, + ** exports): a notebook (and its cells, versions and snapshots) is visible + ** to its owner (sqlWorkbenchUser = ownerUserId) OR to an audience it is + ** shared with (a share row whose audienceKey is one of the session's + ** sqlWorkbenchAudience values); only the owner can change it. An + ** execution is visible to its user only. Workbench admins hold the + ** all-access key and see every row. + ** - Append-only: versions and the execution audit have no insert / edit / + ** delete capability, and a customizer rejects those writes in the core + ** actions unless the qbit itself (system user) makes them. + ** - Customizers keep the notebook invariants for edits made through the + ** tables exactly as through the notebook UI (ownership, cell order and + ** limits, a new version per change, share keys, library visibility). *******************************************************************************/ public class SqlWorkbenchTableMetaDataHelper { + public static final String JOIN_NOTEBOOK_CELL = "sqlWorkbenchNotebookJoinSqlWorkbenchCell"; + public static final String JOIN_NOTEBOOK_VERSION = "sqlWorkbenchNotebookJoinSqlWorkbenchNotebookVersion"; + public static final String JOIN_NOTEBOOK_SNAPSHOT = "sqlWorkbenchNotebookJoinSqlWorkbenchResultSnapshot"; + public static final String JOIN_NOTEBOOK_SHARE = "sqlWorkbenchNotebookJoinSqlWorkbenchNotebookShare"; + public static final String JOIN_SHARE_NOTEBOOK = "sqlWorkbenchNotebookShareJoinSqlWorkbenchNotebook"; + + /******************************************************************************* ** sqlWorkbenchNotebook *******************************************************************************/ public static QTableMetaData buildNotebookTable(SqlWorkbenchQBitConfig config) throws QException { - return (text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson") + QTableMetaData table = text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson") .withRecordLabelFormat("%s") - .withRecordLabelFields("title")); + .withRecordLabelFields("title") + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(ownerLock(config, "ownerUserId")) + .withLock(shareLock(config, config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME) + ".audienceKey", config.applyPrefix(JOIN_SHARE_NOTEBOOK)))) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD)) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "visibility", "ownerName"))) + .withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "defaultBackendName", "parametersJson"))) + .withSection(widgetSection(config, JOIN_NOTEBOOK_CELL, "notes")) + .withSection(widgetSection(config, JOIN_NOTEBOOK_SHARE, "share")) + .withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "push_pin")) + .withSection(widgetSection(config, JOIN_NOTEBOOK_VERSION, "history")) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("ownerUserId", "currentVersion", "createDate", "modifyDate"))); + table.getField("visibility").setIsEditable(false); + table.getField("visibility").setIsRequired(false); + table.getField("visibility").setDefaultValue("PERSONAL"); + table.getField("ownerUserId").setIsRequired(false); + table.getField("parametersJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json"))); + return (customize(table, NotebookTableCustomizer.class)); } @@ -70,51 +129,107 @@ public class SqlWorkbenchTableMetaDataHelper *******************************************************************************/ public static QTableMetaData buildCellTable(SqlWorkbenchQBitConfig config) throws QException { - QTableMetaData table = text(base(config, SqlWorkbenchCell.TABLE_NAME, "SQL Notebook Cell", "notes", SqlWorkbenchCell.class), "content"); - table.setIsHidden(true); - table.withUniqueKey(new UniqueKey("notebookId", "cellKey")); - readOnly(table); - return (linkNotebook(config, table)); + QTableMetaData table = text(base(config, SqlWorkbenchCell.TABLE_NAME, "SQL Notebook Cell", "notes", SqlWorkbenchCell.class), "content") + .withRecordLabelFormat("%s cell %s") + .withRecordLabelFields("notebookId", "sortOrder") + .withUniqueKey(new UniqueKey("notebookId", "cellKey")) + .withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_CELL)) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD)) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "sortOrder", "cellType", "title"))) + .withSection(new QFieldSection("content", new QIcon("code"), Tier.T2, List.of("content", "backendName"))) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("cellKey", "createDate", "modifyDate"))); + table.getField("cellKey").setIsRequired(false); + table.getField("cellType").setIsRequired(false); + table.getField("cellType").setDefaultValue("SQL"); + table.getField("content").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql"))); + return (customize(linkNotebook(config, table), CellTableCustomizer.class)); } /******************************************************************************* - ** sqlWorkbenchNotebookVersion + ** sqlWorkbenchNotebookVersion (append-only) *******************************************************************************/ public static QTableMetaData buildNotebookVersionTable(SqlWorkbenchQBitConfig config) throws QException { - QTableMetaData table = text(base(config, SqlWorkbenchNotebookVersion.TABLE_NAME, "SQL Notebook Version", "history", SqlWorkbenchNotebookVersion.class), "contentJson"); - table.setIsHidden(true); - table.withUniqueKey(new UniqueKey("notebookId", "versionNumber")); - readOnly(table); + QTableMetaData table = text(base(config, SqlWorkbenchNotebookVersion.TABLE_NAME, "SQL Notebook Version", "history", SqlWorkbenchNotebookVersion.class), "contentJson") + .withRecordLabelFormat("%s v%s") + .withRecordLabelFields("notebookId", "versionNumber") + .withUniqueKey(new UniqueKey("notebookId", "versionNumber")) + .withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_VERSION)) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "versionNumber", "comment"))) + .withSection(new QFieldSection("content", new QIcon("code"), Tier.T2, List.of("contentJson"))) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("savedByName", "savedByUserId", "createDate"))); + table.getField("comment").setIsEditable(false); + table.getField("contentJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json"))); + appendOnly(table); return (linkNotebook(config, table)); } /******************************************************************************* - ** sqlWorkbenchResultSnapshot + ** sqlWorkbenchResultSnapshot (pinned from the notebook UI; label edit and + ** delete through the table) *******************************************************************************/ public static QTableMetaData buildResultSnapshotTable(SqlWorkbenchQBitConfig config) throws QException { - QTableMetaData table = text(base(config, SqlWorkbenchResultSnapshot.TABLE_NAME, "SQL Result Snapshot", "push_pin", SqlWorkbenchResultSnapshot.class), "sqlText", "resultJson"); - table.setIsHidden(true); - readOnly(table); - return (linkNotebook(config, table)); + QTableMetaData table = text(base(config, SqlWorkbenchResultSnapshot.TABLE_NAME, "SQL Result Snapshot", "push_pin", SqlWorkbenchResultSnapshot.class), "sqlText", "resultJson") + .withRecordLabelFormat("%s %s") + .withRecordLabelFields("notebookId", "label") + .withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_SNAPSHOT)) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD)) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "label", "cellKey"))) + .withSection(new QFieldSection("result", new QIcon("table_chart"), Tier.T2, List.of("backendName", "rowCount", "truncated", "executionUuid", "sqlText", "resultJson"))) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createdByUserId", "createDate"))); + table.getField("sqlText").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql"))); + table.getField("resultJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json"))); + table.withoutCapability(Capability.TABLE_INSERT); + return (customize(linkNotebook(config, table), SnapshotTableCustomizer.class)); } /******************************************************************************* - ** sqlWorkbenchExecution (the audit) + ** sqlWorkbenchNotebookShare: who, besides the owner, can read a notebook. + *******************************************************************************/ + public static QTableMetaData buildNotebookShareTable(SqlWorkbenchQBitConfig config) throws QException + { + QTableMetaData table = base(config, SqlWorkbenchNotebookShare.TABLE_NAME, "SQL Notebook Share", "share", SqlWorkbenchNotebookShare.class) + .withRecordLabelFormat("%s - %s") + .withRecordLabelFields("notebookId", "audienceKey") + .withUniqueKey(new UniqueKey("notebookId", "audienceKey")) + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(ownerLock(config, config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME) + ".ownerUserId", config.applyPrefix(JOIN_NOTEBOOK_SHARE))) + .withLock(shareLock(config, "audienceKey"))) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "audienceType", "audienceId"))) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("audienceKey", "createdByName", "createdByUserId", "createDate", "modifyDate"))); + table.getField("audienceType").setPossibleValueSourceName(config.applyPrefix(ShareAudienceType.NAME)); + return (customize(linkNotebook(config, table), ShareTableCustomizer.class)); + } + + + + /******************************************************************************* + ** sqlWorkbenchExecution (the audit; append-only) *******************************************************************************/ public static QTableMetaData buildExecutionTable(SqlWorkbenchQBitConfig config) throws QException { - QTableMetaData table = text(base(config, SqlWorkbenchExecution.TABLE_NAME, "SQL Execution Audit", "policy", SqlWorkbenchExecution.class), "sqlText", "parametersJson", "errorMessage"); - table.withUniqueKey(new UniqueKey("executionUuid")); - readOnly(table); - table.withRecordLabelFormat("%s %s").withRecordLabelFields("mode", "executionUuid"); + QTableMetaData table = text(base(config, SqlWorkbenchExecution.TABLE_NAME, "SQL Execution Audit", "policy", SqlWorkbenchExecution.class), "sqlText", "parametersJson", "errorMessage") + .withUniqueKey(new UniqueKey("executionUuid")) + .withRecordLabelFormat("%s %s") + .withRecordLabelFields("mode", "executionUuid") + .withRecordSecurityLock(ownerLock(config, "userId")) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "executionUuid", "mode", "status", "backendName"))) + .withSection(new QFieldSection("run", new QIcon("timer"), Tier.T2, List.of("userName", "userId", "startTime", "endTime", "durationMs", "rowsReturned", "rowsAffected", "truncated", + "statementCount", "statementKinds", "notebookId", "cellKey", "clientIp"))) + .withSection(new QFieldSection("sql", new QIcon("code"), Tier.T2, List.of("sqlText", "sqlHash", "parametersJson", "errorMessage"))) + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate"))); + table.getField("sqlText").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql"))); + table.getField("notebookId").setPossibleValueSourceName(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)); + appendOnly(table); return (table); } @@ -122,6 +237,8 @@ public class SqlWorkbenchTableMetaDataHelper /******************************************************************************* ** Possible value source over the notebook table, for notebookId fields. + ** Lookups go through the table's locks, so they only show readable + ** notebooks. *******************************************************************************/ public static QPossibleValueSource buildNotebookPossibleValueSource(SqlWorkbenchQBitConfig config) { @@ -131,11 +248,91 @@ public class SqlWorkbenchTableMetaDataHelper /******************************************************************************* - ** All five tables. + ** Possible value source for share audience types. + *******************************************************************************/ + public static QPossibleValueSource buildAudienceTypePossibleValueSource(SqlWorkbenchQBitConfig config) + { + return (QPossibleValueSource.newForEnum(config.applyPrefix(ShareAudienceType.NAME), ShareAudienceType.values())); + } + + + + /******************************************************************************* + ** All six tables. *******************************************************************************/ public static List buildAllTables(SqlWorkbenchQBitConfig config) throws QException { - return (List.of(buildNotebookTable(config), buildCellTable(config), buildNotebookVersionTable(config), buildResultSnapshotTable(config), buildExecutionTable(config))); + return (List.of(buildNotebookTable(config), buildCellTable(config), buildNotebookVersionTable(config), buildResultSnapshotTable(config), + buildNotebookShareTable(config), buildExecutionTable(config))); + } + + + + /******************************************************************************* + ** The joins: notebook to each child (one-to-many), and share to notebook + ** (many-to-one, the direction the share locks walk). + *******************************************************************************/ + public static List buildJoins(SqlWorkbenchQBitConfig config) + { + String notebook = config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME); + Map children = new LinkedHashMap<>(); + children.put(JOIN_NOTEBOOK_CELL, SqlWorkbenchCell.TABLE_NAME); + children.put(JOIN_NOTEBOOK_VERSION, SqlWorkbenchNotebookVersion.TABLE_NAME); + children.put(JOIN_NOTEBOOK_SNAPSHOT, SqlWorkbenchResultSnapshot.TABLE_NAME); + children.put(JOIN_NOTEBOOK_SHARE, SqlWorkbenchNotebookShare.TABLE_NAME); + + List joins = new ArrayList<>(); + for(Map.Entry entry : children.entrySet()) + { + joins.add(new QJoinMetaData() + .withName(config.applyPrefix(entry.getKey())) + .withLeftTable(notebook) + .withRightTable(config.applyPrefix(entry.getValue())) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "notebookId"))); + } + joins.add(new QJoinMetaData() + .withName(config.applyPrefix(JOIN_SHARE_NOTEBOOK)) + .withLeftTable(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME)) + .withRightTable(notebook) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("notebookId", "id"))); + return (joins); + } + + + + /******************************************************************************* + ** Child-record list widgets on the notebook's record view: cells (add and + ** edit in place), shares (add, for users who may share), pinned results, + ** versions. + *******************************************************************************/ + public static List buildChildListWidgets(SqlWorkbenchQBitConfig config) + { + Map joins = new LinkedHashMap<>(); + for(QJoinMetaData join : buildJoins(config)) + { + joins.put(join.getName(), join); + } + + List widgets = new ArrayList<>(); + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_CELL))) + .withLabel("Cells").withCanAddChildRecord(true).withMaxRows(200) + .withOnlyIncludeFieldNames(List.of("id", "sortOrder", "cellType", "title", "backendName", "modifyDate")) + .getWidgetMetaData()); + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SHARE))) + .withLabel("Shared With").withCanAddChildRecord(true) + .withOnlyIncludeFieldNames(List.of("id", "audienceType", "audienceId", "createdByName", "createDate")) + .getWidgetMetaData()); + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SNAPSHOT))) + .withLabel("Pinned Results") + .withOnlyIncludeFieldNames(List.of("id", "label", "cellKey", "rowCount", "truncated", "createDate")) + .getWidgetMetaData()); + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_VERSION))) + .withLabel("Versions").withMaxRows(25) + .withOnlyIncludeFieldNames(List.of("id", "versionNumber", "comment", "savedByName", "createDate")) + .getWidgetMetaData()); + return (widgets); } @@ -151,7 +348,7 @@ public class SqlWorkbenchTableMetaDataHelper .withIcon(new QIcon(icon)) .withBackendName(config.getBackendName()) .withPrimaryKeyField("id") - .withPermissionRules(config.getAdminPermissionRules()) + .withPermissionRules(config.getTablePermissionRules()) .withFieldsFromEntity(entity); return (table); } @@ -159,11 +356,91 @@ public class SqlWorkbenchTableMetaDataHelper /******************************************************************************* - ** Only the qbit (as the system user) writes this table. + ** Owner lock: the session's sqlWorkbenchUser value must equal the field + ** (read and write). *******************************************************************************/ - private static void readOnly(QTableMetaData table) + static RecordSecurityLock ownerLock(SqlWorkbenchQBitConfig config, String fieldName, String... joinNameChain) + { + RecordSecurityLock lock = new RecordSecurityLock() + .withSecurityKeyType(config.getUserSecurityKeyTypeName()) + .withFieldName(fieldName) + .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE); + if(joinNameChain.length > 0) + { + lock.setJoinNameChain(List.of(joinNameChain)); + } + return (lock); + } + + + + /******************************************************************************* + ** Share lock: a share row's audienceKey is one of the session's + ** sqlWorkbenchAudience values (read only). + *******************************************************************************/ + static RecordSecurityLock shareLock(SqlWorkbenchQBitConfig config, String fieldName, String... joinNameChain) + { + RecordSecurityLock lock = new RecordSecurityLock() + .withSecurityKeyType(config.getAudienceSecurityKeyTypeName()) + .withFieldName(fieldName) + .withLockScope(RecordSecurityLock.LockScope.READ); + if(joinNameChain.length > 0) + { + lock.setJoinNameChain(List.of(joinNameChain)); + } + return (lock); + } + + + + /******************************************************************************* + ** Lock for a notebook child table: the notebook's owner, or (read only) + ** an audience the notebook is shared with. + *******************************************************************************/ + private static MultiRecordSecurityLock childLock(SqlWorkbenchQBitConfig config, String notebookJoinChild) + { + return (new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(ownerLock(config, config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME) + ".ownerUserId", config.applyPrefix(notebookJoinChild))) + .withLock(shareLock(config, config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME) + ".audienceKey", config.applyPrefix(JOIN_SHARE_NOTEBOOK), config.applyPrefix(notebookJoinChild)))); + } + + + + /******************************************************************************* + ** A record-view section that shows one of the child-list widgets. + *******************************************************************************/ + private static QFieldSection widgetSection(SqlWorkbenchQBitConfig config, String joinName, String icon) + { + return (new QFieldSection(config.applyPrefix(joinName), new QIcon(icon), Tier.T2).withWidgetName(config.applyPrefix(joinName))); + } + + + + /******************************************************************************* + ** Only the qbit (system user) writes this table: the capabilities hide + ** writes in the UI / API, the customizer rejects them in the core actions. + *******************************************************************************/ + private static void appendOnly(QTableMetaData table) { table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE); + customize(table, AppendOnlyTableCustomizer.class); + } + + + + /******************************************************************************* + ** Register one customizer class for every insert / update / delete hook. + *******************************************************************************/ + private static QTableMetaData customize(QTableMetaData table, Class customizer) + { + QCodeReference reference = new QCodeReference(customizer); + for(TableCustomizers role : List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.POST_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD, + TableCustomizers.POST_UPDATE_RECORD, TableCustomizers.PRE_DELETE_RECORD, TableCustomizers.POST_DELETE_RECORD)) + { + table.withCustomizer(role, reference); + } + return (table); } diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/model/ShareAudienceType.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/model/ShareAudienceType.java new file mode 100644 index 0000000..a7165c2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/model/ShareAudienceType.java @@ -0,0 +1,116 @@ +/* + * 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.model; + + +import java.util.Locale; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; + + +/******************************************************************************* + ** Who a notebook share grants read access to. + *******************************************************************************/ +public enum ShareAudienceType implements PossibleValueEnum +{ + USER("User", "user"), + GROUP("Group", "group"), + ALL("Everyone (shared library)", "all"); + + public static final String NAME = "sqlWorkbenchShareAudienceType"; + + private final String label; + private final String keyPrefix; + + + + /******************************************************************************* + ** + *******************************************************************************/ + ShareAudienceType(String label, String keyPrefix) + { + this.label = label; + this.keyPrefix = keyPrefix; + } + + + + /******************************************************************************* + ** The audience key for this type and id: "user:alice", "group:analysts", + ** or "all". Ids are trimmed and lower-cased, as are the session's values. + *******************************************************************************/ + public String audienceKey(String audienceId) + { + if(this == ALL) + { + return (keyPrefix); + } + return (keyPrefix + ":" + normalize(audienceId)); + } + + + + /******************************************************************************* + ** Trim and lower-case an audience id (null stays null). + *******************************************************************************/ + public static String normalize(String audienceId) + { + return (audienceId == null ? null : audienceId.trim().toLowerCase(Locale.ROOT)); + } + + + + /******************************************************************************* + ** Look up by name, ignoring case; null when unknown. + *******************************************************************************/ + public static ShareAudienceType of(Object value) + { + if(value == null) + { + return (null); + } + for(ShareAudienceType type : values()) + { + if(type.name().equalsIgnoreCase(value.toString().trim())) + { + return (type); + } + } + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getPossibleValueId() + { + return (name()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getPossibleValueLabel() + { + return (label); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/model/SqlWorkbenchNotebookShare.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/model/SqlWorkbenchNotebookShare.java new file mode 100644 index 0000000..e0678d8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/model/SqlWorkbenchNotebookShare.java @@ -0,0 +1,363 @@ +/* + * 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.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; + + +/******************************************************************************* + ** One audience a notebook is shared with (read-only): a user, a group, or + ** everyone with workbench access (audienceType ALL - the shared library). + ** audienceKey ("user:alice", "group:analysts", "all") is computed by the + ** table customizer and is what the record security locks match against the + ** session's sqlWorkbenchAudience key values. + *******************************************************************************/ +public class SqlWorkbenchNotebookShare extends QRecordEntity +{ + public static final String TABLE_NAME = "sqlWorkbenchNotebookShare"; + + @QField(isEditable = false, isPrimaryKey = true) + private Integer id; + + @QField(isRequired = true, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME) + private Integer notebookId; + + @QField(isRequired = true, maxLength = 16, possibleValueSourceName = ShareAudienceType.NAME) + private String audienceType; + + @QField(maxLength = 255, label = "User or Group") + private String audienceId; + + @QField(isEditable = false, maxLength = 300) + private String audienceKey; + + @QField(isEditable = false, maxLength = 255) + private String createdByUserId; + + @QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) + private String createdByName; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + + + /******************************************************************************* + ** Default constructor. + *******************************************************************************/ + public SqlWorkbenchNotebookShare() + { + } + + + + /******************************************************************************* + ** Constructor that populates from a QRecord. + *******************************************************************************/ + public SqlWorkbenchNotebookShare(QRecord record) + { + populateFromQRecord(record); + } + + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public SqlWorkbenchNotebookShare withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for notebookId + *******************************************************************************/ + public Integer getNotebookId() + { + return (this.notebookId); + } + + + + /******************************************************************************* + ** Setter for notebookId + *******************************************************************************/ + public void setNotebookId(Integer notebookId) + { + this.notebookId = notebookId; + } + + + + /******************************************************************************* + ** Fluent setter for notebookId + *******************************************************************************/ + public SqlWorkbenchNotebookShare withNotebookId(Integer notebookId) + { + this.notebookId = notebookId; + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceType + *******************************************************************************/ + public String getAudienceType() + { + return (this.audienceType); + } + + + + /******************************************************************************* + ** Setter for audienceType + *******************************************************************************/ + public void setAudienceType(String audienceType) + { + this.audienceType = audienceType; + } + + + + /******************************************************************************* + ** Fluent setter for audienceType + *******************************************************************************/ + public SqlWorkbenchNotebookShare withAudienceType(String audienceType) + { + this.audienceType = audienceType; + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceId + *******************************************************************************/ + public String getAudienceId() + { + return (this.audienceId); + } + + + + /******************************************************************************* + ** Setter for audienceId + *******************************************************************************/ + public void setAudienceId(String audienceId) + { + this.audienceId = audienceId; + } + + + + /******************************************************************************* + ** Fluent setter for audienceId + *******************************************************************************/ + public SqlWorkbenchNotebookShare withAudienceId(String audienceId) + { + this.audienceId = audienceId; + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceKey + *******************************************************************************/ + public String getAudienceKey() + { + return (this.audienceKey); + } + + + + /******************************************************************************* + ** Setter for audienceKey + *******************************************************************************/ + public void setAudienceKey(String audienceKey) + { + this.audienceKey = audienceKey; + } + + + + /******************************************************************************* + ** Fluent setter for audienceKey + *******************************************************************************/ + public SqlWorkbenchNotebookShare withAudienceKey(String audienceKey) + { + this.audienceKey = audienceKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for createdByUserId + *******************************************************************************/ + public String getCreatedByUserId() + { + return (this.createdByUserId); + } + + + + /******************************************************************************* + ** Setter for createdByUserId + *******************************************************************************/ + public void setCreatedByUserId(String createdByUserId) + { + this.createdByUserId = createdByUserId; + } + + + + /******************************************************************************* + ** Fluent setter for createdByUserId + *******************************************************************************/ + public SqlWorkbenchNotebookShare withCreatedByUserId(String createdByUserId) + { + this.createdByUserId = createdByUserId; + return (this); + } + + + + /******************************************************************************* + ** Getter for createdByName + *******************************************************************************/ + public String getCreatedByName() + { + return (this.createdByName); + } + + + + /******************************************************************************* + ** Setter for createdByName + *******************************************************************************/ + public void setCreatedByName(String createdByName) + { + this.createdByName = createdByName; + } + + + + /******************************************************************************* + ** Fluent setter for createdByName + *******************************************************************************/ + public SqlWorkbenchNotebookShare withCreatedByName(String createdByName) + { + this.createdByName = createdByName; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public SqlWorkbenchNotebookShare withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public SqlWorkbenchNotebookShare withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookService.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookService.java index 6a56cba..78e2ccd 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookService.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookService.java @@ -18,26 +18,42 @@ package com.kingsrook.qbits.sqlworkbench.notebook; import java.io.Serializable; -import java.time.Instant; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import com.fasterxml.jackson.core.type.TypeReference; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.customizers.SqlWorkbenchActionFlag; +import com.kingsrook.qbits.sqlworkbench.engine.Caller; +import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult; +import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo; +import com.kingsrook.qbits.sqlworkbench.engine.ResultCache; +import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -46,39 +62,28 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; -import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; -import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; -import com.kingsrook.qbits.sqlworkbench.SystemUser; -import com.kingsrook.qbits.sqlworkbench.engine.Caller; -import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult; -import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo; -import com.kingsrook.qbits.sqlworkbench.engine.ResultCache; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; /******************************************************************************* - ** Notebooks, their cells, versions and pinned result snapshots. + ** Notebooks, their cells, versions, pinned result snapshots and shares, for + ** the notebook UI. ** - ** Visibility: a caller reads their own notebooks and every SHARED one; only - ** the owner edits, deletes, restores and pins; publishing needs the share - ** permission and ownership; anyone with access can duplicate what they can - ** read. + ** Everything here runs as the caller's own session through the standard + ** QQQ table actions, so the same record locks and table customizers apply + ** as for the table UI and the /qqq/v1 table API: a caller reads their own + ** notebooks and the ones shared with them (a user / group share, or the + ** shared library), and only the owner changes one. The explicit checks + ** (readable / owned / canShare) stay as defense in depth and to give the + ** notebook UI its 404 / 403 answers. Only version bookkeeping runs as the + ** system user (NotebookVersioning), because versions are append-only. ** - ** These checks are the boundary: the tables themselves are guarded by the - ** admin permission (sqlWorkbenchAdmin.hasAccess), which ordinary users do - ** not hold, so the generic QQQ table UI / API cannot reach other users' - ** notebooks. Every read and write here runs as the system user (SystemUser) - ** and every public method checks the Caller first (readable / owned / - ** canShare) before it reads content or writes anything. - ** - ** Saves replace a notebook's cells and append a version (full JSON), in one - ** transaction; versions beyond notebookVersionRetention are pruned. + ** Saves replace a notebook's cells and append one version (full JSON), in + ** one transaction; versions beyond notebookVersionRetention are pruned. *******************************************************************************/ public class NotebookService { @@ -115,24 +120,46 @@ public class NotebookService /******************************************************************************* - ** Notebook summaries: scope "mine" (default) or "shared". + ** Make sure the session carries the workbench keys (hosts normally apply + ** them at login, with groups). + *******************************************************************************/ + private void prepare() + { + SqlWorkbenchSecurity.ensureSessionKeys(config, QContext.getQSession()); + } + + + + /******************************************************************************* + ** Notebook summaries: scope "mine" (default) or "shared" (shared with the + ** caller - directly, through a group, or in the library - and not theirs). *******************************************************************************/ public List> list(String scope, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + QQueryFilter filter; + if("shared".equalsIgnoreCase(scope)) { - QQueryFilter filter = "shared".equalsIgnoreCase(scope) - ? new QQueryFilter(new QFilterCriteria("visibility", QCriteriaOperator.EQUALS, VISIBILITY_SHARED)) - : new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, caller.userId())); - filter.withOrderBy(new QFilterOrderBy("modifyDate", false)).withLimit(500); - - List> result = new ArrayList<>(); - for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), filter)) + Set ids = sharedWithCallerNotebookIds(); + if(ids.isEmpty()) { - result.add(summary(new SqlWorkbenchNotebook(record), caller)); + return (new ArrayList<>()); } - return (result); - })); + filter = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<>(ids))) + .withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.NOT_EQUALS, caller.userId())); + } + else + { + filter = new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, caller.userId())); + } + filter.withOrderBy(new QFilterOrderBy("modifyDate", false)).withLimit(500); + + List> result = new ArrayList<>(); + for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), filter)) + { + result.add(summary(new SqlWorkbenchNotebook(record), caller)); + } + return (result); } @@ -142,46 +169,38 @@ public class NotebookService *******************************************************************************/ public Map get(Integer id, Caller caller) throws QException { - return (SystemUser.call(() -> - { - SqlWorkbenchNotebook notebook = readable(id, caller); - Map view = summary(notebook, caller); - view.put("parameters", parseParameters(notebook.getParametersJson())); - view.put("cells", cells(notebook.getId()).stream().map(NotebookService::cellView).toList()); - return (view); - })); + prepare(); + SqlWorkbenchNotebook notebook = readable(id, caller); + Map view = summary(notebook, caller); + view.put("parameters", parseParameters(notebook.getParametersJson())); + view.put("cells", NotebookVersioning.cells(config, notebook.getId(), null).stream().map(NotebookService::cellView).toList()); + return (view); } /******************************************************************************* - ** Create a personal notebook (version 1). + ** Create a personal notebook (version 1). The table customizer makes the + ** caller its owner. *******************************************************************************/ public Map create(Map body, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook(); + applyFields(notebook, body, true); + List cells = parseCells(body.get("cells")); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) { - Instant now = runtime.getClock().instant(); - SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook() - .withOwnerUserId(caller.userId()) - .withOwnerName(caller.userName()) - .withVisibility(VISIBILITY_PERSONAL) - .withCurrentVersion(0) - .withCreateDate(now); - applyFields(notebook, body, true); - List cells = parseCells(body.get("cells")); - - try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) - { - notebook.setModifyDate(now); - InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecordEntity(notebook).withTransaction(transaction)); - throwIfErrors(output.getRecords().get(0)); - notebook.setId(output.getRecords().get(0).getValueInteger("id")); - saveContent(notebook, cells, caller, (String) body.get("comment"), transaction); - commit(transaction); - } - return (get(notebook.getId(), caller)); - })); + InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) + .withRecordEntity(notebook) + .withTransaction(transaction)); + throwIfErrors(output.getRecords().get(0)); + notebook.setId(output.getRecords().get(0).getValueInteger("id")); + saveContent(notebook, cells, (String) body.get("comment"), transaction); + commit(transaction); + } + return (get(notebook.getId(), caller)); } @@ -191,64 +210,64 @@ public class NotebookService *******************************************************************************/ public Map update(Integer id, Map body, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + SqlWorkbenchNotebook notebook = owned(id, caller); + applyFields(notebook, body, false); + List cells = body.containsKey("cells") ? parseCells(body.get("cells")) : NotebookVersioning.cells(config, id, null); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) { - SqlWorkbenchNotebook notebook = owned(id, caller); - applyFields(notebook, body, false); - List cells = body.containsKey("cells") ? parseCells(body.get("cells")) : cells(id); - - try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) - { - saveContent(notebook, cells, caller, (String) body.get("comment"), transaction); - commit(transaction); - } - pruneVersions(id); - return (get(id, caller)); - })); + saveContent(notebook, cells, (String) body.get("comment"), transaction); + commit(transaction); + } + return (get(id, caller)); } /******************************************************************************* - ** Delete a notebook and everything under it. + ** Delete a notebook; its table customizer deletes everything under it. *******************************************************************************/ public void delete(Integer id, Caller caller) throws QException { - SystemUser.run(() -> + prepare(); + owned(id, caller); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) { - owned(id, caller); - try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)))) - { - for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME)) - { - new DeleteAction().execute(new DeleteInput(table(child)).withQueryFilter(byNotebook(id)).withTransaction(transaction)); - } - new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withPrimaryKeys(List.of(id)).withTransaction(transaction)); - commit(transaction); - } - }); + DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withPrimaryKeys(List.of(id)).withTransaction(transaction)); + throwIfErrors(output.getRecordsWithErrors()); + commit(transaction); + } } /******************************************************************************* - ** Publish to (or withdraw from) the shared library. + ** Publish to (or withdraw from) the shared library: an ALL share row. *******************************************************************************/ public Map publish(Integer id, boolean shared, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + if(!caller.canShare()) { - if(!caller.canShare()) - { - throw (new QPermissionDeniedException("Publishing to the shared library needs the " + config.getSharePermissionName() + " permission.")); - } - SqlWorkbenchNotebook notebook = owned(id, caller); - new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecord(new QRecord() - .withValue("id", notebook.getId()) - .withValue("visibility", shared ? VISIBILITY_SHARED : VISIBILITY_PERSONAL) - .withValue("modifyDate", runtime.getClock().instant()))); - return (get(id, caller)); - })); + throw (new QPermissionDeniedException("Publishing to the shared library needs the " + config.getSharePermissionName() + " permission.")); + } + owned(id, caller); + + QQueryFilter libraryShare = byNotebook(id).withCriteria(new QFilterCriteria("audienceKey", QCriteriaOperator.EQUALS, SqlWorkbenchSecurity.AUDIENCE_ALL)); + boolean published = !QueryAction.execute(table(SqlWorkbenchNotebookShare.TABLE_NAME), libraryShare).isEmpty(); + if(shared && !published) + { + InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebookShare.TABLE_NAME)).withRecordEntity(new SqlWorkbenchNotebookShare() + .withNotebookId(id) + .withAudienceType(ShareAudienceType.ALL.name()))); + throwIfErrors(output.getRecords().get(0)); + } + else if(!shared && published) + { + DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebookShare.TABLE_NAME)).withQueryFilter(libraryShare)); + throwIfErrors(output.getRecordsWithErrors()); + } + return (get(id, caller)); } @@ -258,25 +277,23 @@ public class NotebookService *******************************************************************************/ public Map duplicate(Integer id, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + SqlWorkbenchNotebook source = readable(id, caller); + Map body = new LinkedHashMap<>(); + body.put("title", truncate("Copy of " + source.getTitle(), 255)); + body.put("description", source.getDescription()); + body.put("defaultBackendName", source.getDefaultBackendName()); + body.put("parameters", parseParameters(source.getParametersJson())); + List> cells = new ArrayList<>(); + for(SqlWorkbenchCell cell : NotebookVersioning.cells(config, id, null)) { - SqlWorkbenchNotebook source = readable(id, caller); - Map body = new LinkedHashMap<>(); - body.put("title", truncate("Copy of " + source.getTitle(), 255)); - body.put("description", source.getDescription()); - body.put("defaultBackendName", source.getDefaultBackendName()); - body.put("parameters", parseParameters(source.getParametersJson())); - List> cells = new ArrayList<>(); - for(SqlWorkbenchCell cell : cells(id)) - { - Map view = cellView(cell); - view.put("cellKey", UUID.randomUUID().toString()); - cells.add(view); - } - body.put("cells", cells); - body.put("comment", "Duplicated from notebook " + id); - return (create(body, caller)); - })); + Map view = cellView(cell); + view.put("cellKey", UUID.randomUUID().toString()); + cells.add(view); + } + body.put("cells", cells); + body.put("comment", "Duplicated from notebook " + id); + return (create(body, caller)); } @@ -286,23 +303,21 @@ public class NotebookService *******************************************************************************/ public List> versions(Integer id, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + readable(id, caller); + QQueryFilter filter = byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false)); + List> result = new ArrayList<>(); + for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter)) { - readable(id, caller); - QQueryFilter filter = byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false)); - List> result = new ArrayList<>(); - for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter)) - { - SqlWorkbenchNotebookVersion version = new SqlWorkbenchNotebookVersion(record); - Map view = new LinkedHashMap<>(); - view.put("versionNumber", version.getVersionNumber()); - view.put("savedByName", version.getSavedByName()); - view.put("comment", version.getComment()); - view.put("createDate", String.valueOf(version.getCreateDate())); - result.add(view); - } - return (result); - })); + SqlWorkbenchNotebookVersion version = new SqlWorkbenchNotebookVersion(record); + Map view = new LinkedHashMap<>(); + view.put("versionNumber", version.getVersionNumber()); + view.put("savedByName", version.getSavedByName()); + view.put("comment", version.getComment()); + view.put("createDate", String.valueOf(version.getCreateDate())); + result.add(view); + } + return (result); } @@ -312,18 +327,16 @@ public class NotebookService *******************************************************************************/ public Map version(Integer id, Integer versionNumber, Caller caller) throws QException { - return (SystemUser.call(() -> - { - readable(id, caller); - SqlWorkbenchNotebookVersion version = findVersion(id, versionNumber); - Map view = new LinkedHashMap<>(); - view.put("versionNumber", version.getVersionNumber()); - view.put("savedByName", version.getSavedByName()); - view.put("comment", version.getComment()); - view.put("createDate", String.valueOf(version.getCreateDate())); - view.put("notebook", parseJsonObject(version.getContentJson())); - return (view); - })); + prepare(); + readable(id, caller); + SqlWorkbenchNotebookVersion version = findVersion(id, versionNumber); + Map view = new LinkedHashMap<>(); + view.put("versionNumber", version.getVersionNumber()); + view.put("savedByName", version.getSavedByName()); + view.put("comment", version.getComment()); + view.put("createDate", String.valueOf(version.getCreateDate())); + view.put("notebook", parseJsonObject(version.getContentJson())); + return (view); } @@ -333,14 +346,12 @@ public class NotebookService *******************************************************************************/ public Map restore(Integer id, Integer versionNumber, Caller caller) throws QException { - return (SystemUser.call(() -> - { - owned(id, caller); - Map content = parseJsonObject(findVersion(id, versionNumber).getContentJson()); - Map body = new LinkedHashMap<>(content); - body.put("comment", "Restored version " + versionNumber); - return (update(id, body, caller)); - })); + prepare(); + owned(id, caller); + Map content = parseJsonObject(findVersion(id, versionNumber).getContentJson()); + Map body = new LinkedHashMap<>(content); + body.put("comment", "Restored version " + versionNumber); + return (update(id, body, caller)); } @@ -350,16 +361,14 @@ public class NotebookService *******************************************************************************/ public List> snapshots(Integer id, Caller caller) throws QException { - return (SystemUser.call(() -> + prepare(); + readable(id, caller); + List> result = new ArrayList<>(); + for(QRecord record : QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), byNotebook(id).withOrderBy(new QFilterOrderBy("id", false)))) { - readable(id, caller); - List> result = new ArrayList<>(); - for(QRecord record : QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), byNotebook(id).withOrderBy(new QFilterOrderBy("id", false)))) - { - result.add(snapshotView(new SqlWorkbenchResultSnapshot(record))); - } - return (result); - })); + result.add(snapshotView(new SqlWorkbenchResultSnapshot(record))); + } + return (result); } @@ -369,38 +378,35 @@ public class NotebookService *******************************************************************************/ public Map createSnapshot(Integer id, Map body, Caller caller, String sqlText) throws QException { - return (SystemUser.call(() -> - { - owned(id, caller); - String executionUuid = (String) body.get("executionUuid"); - ResultCache.Entry entry = runtime.getResultCache().get(executionUuid, caller.userId()) - .orElseThrow(() -> new QNotFoundException("That result is no longer available; run the cell again, then pin it.")); - - CollectedResult result = entry.result(); - int max = config.getSnapshotMaxRows(); - List> rows = result.rows().size() > max ? result.rows().subList(0, max) : result.rows(); - Map json = new LinkedHashMap<>(); - json.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList()); - json.put("rows", rows); - - SqlWorkbenchResultSnapshot snapshot = new SqlWorkbenchResultSnapshot() - .withNotebookId(id) - .withCellKey((String) body.get("cellKey")) - .withExecutionUuid(executionUuid) - .withLabel(truncate((String) body.get("label"), 255)) - .withBackendName(entry.backendName()) - .withSqlText(sqlText) - .withResultJson(JsonUtils.toJson(json)) - .withRowCount(rows.size()) - .withTruncated(result.truncated() || result.rows().size() > max) - .withCreatedByUserId(caller.userId()) - .withCreateDate(runtime.getClock().instant()); - - InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withRecordEntity(snapshot)); - throwIfErrors(output.getRecords().get(0)); - snapshot.setId(output.getRecords().get(0).getValueInteger("id")); - return (snapshotView(snapshot)); - })); + prepare(); + owned(id, caller); + String executionUuid = (String) body.get("executionUuid"); + ResultCache.Entry entry = runtime.getResultCache().get(executionUuid, caller.userId()) + .orElseThrow(() -> new QNotFoundException("That result is no longer available; run the cell again, then pin it.")); + CollectedResult result = entry.result(); + int max = config.getSnapshotMaxRows(); + List> rows = result.rows().size() > max ? result.rows().subList(0, max) : result.rows(); + Map json = new LinkedHashMap<>(); + json.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList()); + json.put("rows", rows); + SqlWorkbenchResultSnapshot snapshot = new SqlWorkbenchResultSnapshot() + .withNotebookId(id) + .withCellKey((String) body.get("cellKey")) + .withExecutionUuid(executionUuid) + .withLabel(truncate((String) body.get("label"), 255)) + .withBackendName(entry.backendName()) + .withSqlText(sqlText) + .withResultJson(JsonUtils.toJson(json)) + .withRowCount(rows.size()) + .withTruncated(result.truncated() || result.rows().size() > max) + .withCreatedByUserId(caller.userId()) + .withCreateDate(runtime.getClock().instant()); + InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.SNAPSHOT_PIN) + .withRecordEntity(snapshot)); + throwIfErrors(output.getRecords().get(0)); + snapshot.setId(output.getRecords().get(0).getValueInteger("id")); + return (snapshotView(snapshot)); } @@ -410,28 +416,33 @@ public class NotebookService *******************************************************************************/ public void deleteSnapshot(Integer id, Integer snapshotId, Caller caller) throws QException { - SystemUser.run(() -> + prepare(); + owned(id, caller); + QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, snapshotId)); + if(QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), filter).isEmpty()) { - owned(id, caller); - QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, snapshotId)); - if(QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), filter).isEmpty()) - { - throw (new QNotFoundException("Snapshot not found.")); - } - new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withPrimaryKeys(List.of(snapshotId))); - }); + throw (new QNotFoundException("Snapshot not found.")); + } + DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withPrimaryKeys(List.of(snapshotId))); + throwIfErrors(output.getRecordsWithErrors()); } /******************************************************************************* - ** A notebook the caller may read, or 404 (never 403: personal notebooks of - ** others do not exist for the caller). + ** A notebook the caller may read, or 404 (never 403: notebooks not shared + ** with the caller do not exist for them). The record locks already hide + ** them; the owner / share check repeats that decision explicitly. *******************************************************************************/ SqlWorkbenchNotebook readable(Integer id, Caller caller) throws QException { SqlWorkbenchNotebook notebook = find(id); - if(notebook == null || !(Objects.equals(notebook.getOwnerUserId(), caller.userId()) || VISIBILITY_SHARED.equals(notebook.getVisibility()))) + if(notebook == null) + { + throw (new QNotFoundException("Notebook not found.")); + } + boolean owner = Objects.equals(notebook.getOwnerUserId(), caller.userId()); + if(!owner && !SqlWorkbenchSecurity.hasAllAccess(config) && !sharedWithCallerNotebookIds().contains(notebook.getId())) { throw (new QNotFoundException("Notebook not found.")); } @@ -441,7 +452,7 @@ public class NotebookService /******************************************************************************* - ** A notebook the caller owns: 404 if unreadable, 403 if shared but not + ** A notebook the caller owns: 404 if unreadable, 403 if readable but not ** theirs. *******************************************************************************/ SqlWorkbenchNotebook owned(Integer id, Caller caller) throws QException @@ -457,33 +468,37 @@ public class NotebookService /******************************************************************************* - ** + ** The notebook, as the caller's session can see it (or null). *******************************************************************************/ private SqlWorkbenchNotebook find(Integer id) throws QException { - return (SystemUser.call(() -> + if(id == null) { - if(id == null) - { - throw (new QBadRequestException("A notebook id is required.")); - } - List records = QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))); - return (records.isEmpty() ? null : new SqlWorkbenchNotebook(records.get(0))); - })); + throw (new QBadRequestException("A notebook id is required.")); + } + List records = QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))); + return (records.isEmpty() ? null : new SqlWorkbenchNotebook(records.get(0))); } /******************************************************************************* - ** + ** Ids of the notebooks shared with one of the session's audiences. *******************************************************************************/ - private List cells(Integer notebookId) throws QException + private Set sharedWithCallerNotebookIds() throws QException { - return (SystemUser.call(() -> + Set ids = new LinkedHashSet<>(); + List audienceKeys = SqlWorkbenchSecurity.sessionAudienceKeys(config); + if(audienceKeys.isEmpty()) { - QQueryFilter filter = byNotebook(notebookId).withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")); - return (QueryAction.execute(table(SqlWorkbenchCell.TABLE_NAME), SqlWorkbenchCell.class, filter)); - })); + return (ids); + } + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("audienceKey", QCriteriaOperator.IN, new ArrayList<>(audienceKeys))); + for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookShare.TABLE_NAME), filter)) + { + ids.add(record.getValueInteger("notebookId")); + } + return (ids); } @@ -493,16 +508,13 @@ public class NotebookService *******************************************************************************/ private SqlWorkbenchNotebookVersion findVersion(Integer id, Integer versionNumber) throws QException { - return (SystemUser.call(() -> + QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, versionNumber)); + List records = QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter); + if(records.isEmpty()) { - QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, versionNumber)); - List records = QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter); - if(records.isEmpty()) - { - throw (new QNotFoundException("Version not found.")); - } - return (new SqlWorkbenchNotebookVersion(records.get(0))); - })); + throw (new QNotFoundException("Version not found.")); + } + return (new SqlWorkbenchNotebookVersion(records.get(0))); } @@ -518,81 +530,46 @@ public class NotebookService /******************************************************************************* - ** Write the notebook row (version + 1), replace its cells, append a - ** version - all in the given transaction. + ** Write the notebook's fields, replace its cells, append a version - all + ** in the given transaction, as the caller (NOTEBOOK_SAVE tells the table + ** customizers this is one save, versioned once at the end). *******************************************************************************/ - private void saveContent(SqlWorkbenchNotebook notebook, List cells, Caller caller, String comment, QBackendTransaction transaction) throws QException + private void saveContent(SqlWorkbenchNotebook notebook, List cells, String comment, QBackendTransaction transaction) throws QException { - Instant now = runtime.getClock().instant(); - int version = (notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion()) + 1; - notebook.setCurrentVersion(version); - notebook.setModifyDate(now); + UpdateOutput updated = new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) + .withTransaction(transaction) + .withRecord(new QRecord() + .withValue("id", notebook.getId()) + .withValue("title", notebook.getTitle()) + .withValue("description", notebook.getDescription()) + .withValue("defaultBackendName", notebook.getDefaultBackendName()) + .withValue("parametersJson", notebook.getParametersJson()))); + throwIfErrors(updated.getRecords().get(0)); - new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withTransaction(transaction).withRecord(new QRecord() - .withValue("id", notebook.getId()) - .withValue("title", notebook.getTitle()) - .withValue("description", notebook.getDescription()) - .withValue("defaultBackendName", notebook.getDefaultBackendName()) - .withValue("parametersJson", notebook.getParametersJson()) - .withValue("currentVersion", version) - .withValue("modifyDate", now))); + DeleteOutput deleted = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) + .withQueryFilter(byNotebook(notebook.getId())) + .withTransaction(transaction)); + throwIfErrors(deleted.getRecordsWithErrors()); - new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME)).withQueryFilter(byNotebook(notebook.getId())).withTransaction(transaction)); for(int i = 0; i < cells.size(); i++) { - cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i).withCreateDate(now).withModifyDate(now); + cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i); } if(!cells.isEmpty()) { - InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchCell.TABLE_NAME)).withRecordEntities(cells).withTransaction(transaction)); + InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchCell.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) + .withRecordEntities(cells) + .withTransaction(transaction)); for(QRecord record : output.getRecords()) { throwIfErrors(record); } } - Map content = new LinkedHashMap<>(); - content.put("title", notebook.getTitle()); - content.put("description", notebook.getDescription()); - content.put("defaultBackendName", notebook.getDefaultBackendName()); - content.put("parameters", parseParameters(notebook.getParametersJson())); - content.put("cells", cells.stream().map(NotebookService::cellView).toList()); - - SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion() - .withNotebookId(notebook.getId()) - .withVersionNumber(version) - .withContentJson(JsonUtils.toJson(content)) - .withSavedByUserId(caller.userId()) - .withSavedByName(caller.userName()) - .withComment(truncate(comment, 255)) - .withCreateDate(now); - InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withRecordEntity(versionRow).withTransaction(transaction)); - throwIfErrors(output.getRecords().get(0)); - } - - - - /******************************************************************************* - ** Delete versions beyond the retention, oldest first. - *******************************************************************************/ - private void pruneVersions(Integer id) throws QException - { - SystemUser.run(() -> - { - QueryInput input = new QueryInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)) - .withFilter(byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false))); - List records = new QueryAction().execute(input).getRecords(); - if(records.size() <= config.getNotebookVersionRetention()) - { - return; - } - List stale = new ArrayList<>(); - for(QRecord record : records.subList(config.getNotebookVersionRetention(), records.size())) - { - stale.add(record.getValueInteger("id")); - } - new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withPrimaryKeys(stale)); - }); + NotebookVersioning.appendVersion(config, runtime.getClock(), notebook.getId(), comment, transaction); } @@ -655,26 +632,22 @@ public class NotebookService throw (new QBadRequestException("Each cell must be an object.")); } Map cell = (Map) map; - String type = cell.get("cellType") == null ? CELL_SQL : cell.get("cellType").toString().toUpperCase(); if(!CELL_SQL.equals(type) && !CELL_MARKDOWN.equals(type)) { throw (new QBadRequestException("cellType must be SQL or MARKDOWN.")); } - String content = cell.get("content") == null ? "" : cell.get("content").toString(); if(content.length() > config.getMaxSqlLength()) { throw (new QBadRequestException("A cell is longer than the limit of " + config.getMaxSqlLength() + " characters.")); } - String key = cell.get("cellKey") == null ? null : cell.get("cellKey").toString(); if(!StringUtils.hasContent(key) || key.length() > 64 || !keys.add(key)) { key = UUID.randomUUID().toString(); keys.add(key); } - cells.add(new SqlWorkbenchCell() .withCellKey(key) .withCellType(type) @@ -789,13 +762,32 @@ public class NotebookService /******************************************************************************* - ** + ** Throw the first error of a record (a QPermissionDeniedException for + ** permission errors, so the notebook UI answers 403). *******************************************************************************/ - private static void throwIfErrors(QRecord record) throws QException + static void throwIfErrors(QRecord record) throws QException { - if(record.getErrors() != null && !record.getErrors().isEmpty()) + if(record != null && CollectionUtils.nullSafeHasContents(record.getErrors())) { - throw (new QException("Could not save: " + record.getErrors().get(0).getMessage())); + String message = record.getErrors().get(0).getMessage(); + if(record.getErrors().get(0) instanceof com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage) + { + throw (new QPermissionDeniedException(message)); + } + throw (new QBadRequestException("Could not save: " + message)); + } + } + + + + /******************************************************************************* + ** Throw the first error of any record. + *******************************************************************************/ + static void throwIfErrors(List records) throws QException + { + for(QRecord record : CollectionUtils.nonNullList(records)) + { + throwIfErrors(record); } } @@ -821,5 +813,4 @@ public class NotebookService { return (value == null || value.length() <= max ? value : value.substring(0, max - 1) + "…"); } - } diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java new file mode 100644 index 0000000..8043953 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java @@ -0,0 +1,185 @@ +/* + * 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.notebook; + + +import java.io.Serializable; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.SystemUser; +import com.kingsrook.qbits.sqlworkbench.customizers.SqlWorkbenchActionFlag; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** Notebook versions: every change to a notebook's content (from the + ** notebook UI or from the table UI / API) appends one immutable version + ** holding the whole notebook, and bumps the notebook's currentVersion. + ** + ** The caller's change has already passed the record locks (only the owner, + ** or an admin, gets here); the bookkeeping itself is written as the system + ** user, because versions are append-only for everyone else. + *******************************************************************************/ +public final class NotebookVersioning +{ + + /******************************************************************************* + ** + *******************************************************************************/ + private NotebookVersioning() + { + } + + + + /******************************************************************************* + ** Append a version of the notebook as stored now (in the transaction, if + ** any), saved by the current session's user. Returns the new version + ** number, or null if the notebook does not exist. + *******************************************************************************/ + public static Integer appendVersion(SqlWorkbenchQBitConfig config, Clock clock, Integer notebookId, String comment, QBackendTransaction transaction) throws QException + { + QSession session = QContext.getQSession(); + String userId = SqlWorkbenchSecurity.userId(session); + String userName = SqlWorkbenchSecurity.userName(session); + + return (SystemUser.call(() -> + { + List found = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) + .withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, notebookId))) + .withTransaction(transaction)).getRecords(); + if(found.isEmpty()) + { + return (null); + } + + SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook(found.get(0)); + Instant now = clock.instant(); + int version = (notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion()) + 1; + + new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.INTERNAL) + .withTransaction(transaction) + .withRecord(new QRecord().withValue("id", notebookId).withValue("currentVersion", version).withValue("modifyDate", now))); + + SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion() + .withNotebookId(notebookId) + .withVersionNumber(version) + .withContentJson(JsonUtils.toJson(content(notebook, cells(config, notebookId, transaction)))) + .withSavedByUserId(userId) + .withSavedByName(userName) + .withComment(NotebookService.truncate(comment, 255)) + .withCreateDate(now); + InsertOutput output = new InsertAction().execute(new InsertInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.INTERNAL) + .withTransaction(transaction) + .withRecordEntity(versionRow)); + NotebookService.throwIfErrors(output.getRecords().get(0)); + + prune(config, notebookId, transaction); + return (version); + })); + } + + + + /******************************************************************************* + ** A notebook's cells, in order (as the current session). + *******************************************************************************/ + static List cells(SqlWorkbenchQBitConfig config, Integer notebookId, QBackendTransaction transaction) throws QException + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)) + .withOrderBy(new QFilterOrderBy("sortOrder")) + .withOrderBy(new QFilterOrderBy("id")); + List cells = new ArrayList<>(); + for(QRecord record : new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchCell.TABLE_NAME)).withFilter(filter).withTransaction(transaction)).getRecords()) + { + cells.add(new SqlWorkbenchCell(record)); + } + return (cells); + } + + + + /******************************************************************************* + ** The content a version holds. + *******************************************************************************/ + static Map content(SqlWorkbenchNotebook notebook, List cells) + { + Map content = new LinkedHashMap<>(); + content.put("title", notebook.getTitle()); + content.put("description", notebook.getDescription()); + content.put("defaultBackendName", notebook.getDefaultBackendName()); + content.put("parameters", NotebookService.parseParameters(notebook.getParametersJson())); + content.put("cells", cells.stream().map(NotebookService::cellView).toList()); + return (content); + } + + + + /******************************************************************************* + ** Delete versions beyond the retention, oldest first (system user). + *******************************************************************************/ + private static void prune(SqlWorkbenchQBitConfig config, Integer notebookId, QBackendTransaction transaction) throws QException + { + QueryInput input = new QueryInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME)) + .withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)).withOrderBy(new QFilterOrderBy("versionNumber", false))) + .withTransaction(transaction); + List records = new QueryAction().execute(input).getRecords(); + if(records.size() <= config.getNotebookVersionRetention()) + { + return; + } + + List stale = new ArrayList<>(); + for(QRecord record : records.subList(config.getNotebookVersionRetention(), records.size())) + { + stale.add(record.getValueInteger("id")); + } + new DeleteAction().execute(new DeleteInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME)) + .withFlag(SqlWorkbenchActionFlag.INTERNAL) + .withTransaction(transaction) + .withPrimaryKeys(stale)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/processes/SqlWorkbenchRecordActionSteps.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/processes/SqlWorkbenchRecordActionSteps.java new file mode 100644 index 0000000..df56c5f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/processes/SqlWorkbenchRecordActionSteps.java @@ -0,0 +1,163 @@ +/* + * 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.processes; + + +import java.util.Map; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity; +import com.kingsrook.qbits.sqlworkbench.engine.Caller; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** The backend steps of the workbench's record actions. QQQ loads the + ** selected records through the table's record locks (as the session), so a + ** step only ever sees rows the user can read; the writes then go through + ** the same table actions and customizers as everything else. + *******************************************************************************/ +public final class SqlWorkbenchRecordActionSteps +{ + public static final String RESULT_MESSAGE = "resultMessage"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + private SqlWorkbenchRecordActionSteps() + { + } + + + + /******************************************************************************* + ** The single selected record (min/max input records are 1). + *******************************************************************************/ + static QRecord selected(RunBackendStepInput input) throws QException + { + if(CollectionUtils.nullSafeIsEmpty(input.getRecords())) + { + throw (new QUserFacingException("The selected record was not found.")); + } + return (input.getRecords().get(0)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static NotebookService notebooks() throws QException + { + return (new NotebookService(SqlWorkbenchRuntime.get())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Caller caller() throws QException + { + SqlWorkbenchQBitConfig config = SqlWorkbenchRuntime.get().getConfig(); + SqlWorkbenchSecurity.ensureSessionKeys(config, QContext.getQSession()); + return (Caller.fromSession(config, QContext.getQSession(), null)); + } + + + + /******************************************************************************* + ** Share the selected notebook with a user, a group, or everyone (the + ** shared library): inserts a share row as the session. + *******************************************************************************/ + public static class ShareNotebookStep implements BackendStep + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + QRecord notebook = selected(input); + SqlWorkbenchQBitConfig config = SqlWorkbenchRuntime.get().getConfig(); + InsertOutput inserted = new InsertAction().execute(new InsertInput(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME)).withRecordEntity(new SqlWorkbenchNotebookShare() + .withNotebookId(notebook.getValueInteger("id")) + .withAudienceType(input.getValueString("audienceType")) + .withAudienceId(input.getValueString("audienceId")))); + QRecord share = inserted.getRecords().get(0); + if(CollectionUtils.nullSafeHasContents(share.getErrors())) + { + throw (new QUserFacingException(share.getErrors().get(0).getMessage())); + } + output.addValue(RESULT_MESSAGE, "Shared \"" + notebook.getValueString("title") + "\" (" + share.getValueString("audienceKey") + ")."); + } + } + + + + /******************************************************************************* + ** Copy the selected (readable) notebook into a new notebook the session + ** owns. + *******************************************************************************/ + public static class DuplicateNotebookStep implements BackendStep + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + QRecord notebook = selected(input); + Map copy = notebooks().duplicate(notebook.getValueInteger("id"), caller()); + output.addValue(RESULT_MESSAGE, "Created \"" + copy.get("title") + "\" (notebook " + copy.get("id") + ")."); + } + } + + + + /******************************************************************************* + ** Restore the selected version of a notebook the session owns (saved as + ** a new version). + *******************************************************************************/ + public static class RestoreVersionStep implements BackendStep + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + QRecord version = selected(input); + Map restored = notebooks().restore(version.getValueInteger("notebookId"), version.getValueInteger("versionNumber"), caller()); + output.addValue(RESULT_MESSAGE, "Restored version " + version.getValueInteger("versionNumber") + " as version " + restored.get("currentVersion") + "."); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/BaseSqlWorkbenchTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/BaseSqlWorkbenchTest.java index 7b522b2..71bab5d 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/BaseSqlWorkbenchTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/BaseSqlWorkbenchTest.java @@ -203,15 +203,28 @@ public class BaseSqlWorkbenchTest /******************************************************************************* ** Init QContext with a session for one of the test users. *******************************************************************************/ - protected QSession loginAs(String userId) + protected QSession loginAs(String userId, String... groups) { QSession session = TestSessionCustomizer.sessionFor(userId); QContext.init(qInstance, session); + SqlWorkbenchSecurity.applyToSession(config, session, List.of(groups)); return (session); } + /******************************************************************************* + ** Log in as a user and return their Caller (the session and the Caller + ** must agree: notebook operations run as the session). + *******************************************************************************/ + protected Caller as(String userId, String... groups) + { + loginAs(userId, groups); + return (caller(userId)); + } + + + /******************************************************************************* ** The caller for a test user (permissions as TestSessionCustomizer grants). *******************************************************************************/ @@ -240,6 +253,14 @@ public class BaseSqlWorkbenchTest QSession granted = sessionFor(qSession.getUuid()); qSession.setUser(granted.getUser()); qSession.setPermissions(granted.getPermissions()); + try + { + SqlWorkbenchSecurity.applyToSession(qInstance, qSession, List.of()); + } + catch(Exception e) + { + throw (new IllegalStateException("SQL Workbench is not installed", e)); + } } @@ -249,20 +270,21 @@ public class BaseSqlWorkbenchTest *******************************************************************************/ public static QSession sessionFor(String userId) { - QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + userId)); + SqlWorkbenchQBitConfig defaults = new SqlWorkbenchQBitConfig(); + QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + userId)); session.setPermissions(new java.util.HashSet<>()); - if(userId != null && (userId.startsWith("reader") || userId.startsWith("writer") || userId.startsWith("admin"))) + if(userId != null && (userId.startsWith("reader") || userId.startsWith("writer"))) { - session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ACCESS_PERMISSION_BASE_NAME + ".hasAccess"); + session.getPermissions().addAll(defaults.getUserPermissionNames()); } if(userId != null && userId.startsWith("admin")) { - session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ADMIN_PERMISSION_BASE_NAME + ".hasAccess"); + session.getPermissions().addAll(defaults.getAdminPermissionNames()); } if(userId != null && userId.startsWith("writer")) { session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_WRITE_PERMISSION_NAME); - session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_SHARE_PERMISSION_NAME); + session.getPermissions().addAll(defaults.getSharePermissionNames()); } return (session); } diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/PostgresIntegrationTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/PostgresIntegrationTest.java index dcbc720..4707c91 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/PostgresIntegrationTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/PostgresIntegrationTest.java @@ -41,6 +41,15 @@ import com.kingsrook.qbits.sqlworkbench.engine.SessionGuard; import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; import com.kingsrook.qbits.sqlworkbench.schema.SchemaIntrospector; import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect; import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; @@ -117,7 +126,7 @@ class PostgresIntegrationTest try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); Statement statement = connection.createStatement()) { - statement.execute("TRUNCATE sql_workbench_result_snapshot, sql_workbench_notebook_version, sql_workbench_cell, sql_workbench_notebook, sql_workbench_execution"); + statement.execute("TRUNCATE sql_workbench_notebook_share, sql_workbench_result_snapshot, sql_workbench_notebook_version, sql_workbench_cell, sql_workbench_notebook, sql_workbench_execution"); statement.execute("DROP TABLE IF EXISTS scratch"); } @@ -231,7 +240,7 @@ class PostgresIntegrationTest statement.execute("DELETE FROM person WHERE first_name = 'after'"); } - List audits = QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()); + List audits = SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter())); assertThat(audits).extracting(SqlWorkbenchExecution::getStatus).contains("SUCCEEDED", "TIMED_OUT"); } @@ -385,4 +394,54 @@ class PostgresIntegrationTest } } + + /******************************************************************************* + ** The record locks on PostgreSQL: owner / user share / library reads + ** through the core actions, one row per notebook even when it matches + ** several shares (SELECT DISTINCT / COUNT(DISTINCT) over TEXT columns), + ** and a reader cannot write a shared notebook's children. + *******************************************************************************/ + @Test + void testRecordLocksOnPostgres() throws Exception + { + NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance)); + login("writer-1"); + Integer both = (Integer) notebooks.create(Map.of("title", "Both", "description", "long text", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id"); + Integer personal = (Integer) notebooks.create(Map.of("title", "Personal", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 2"))), writer()).get("id"); + notebooks.publish(both, true, writer()); + QRecord share = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebookShare.TABLE_NAME).withRecord(new QRecord() + .withValue("notebookId", both).withValue("audienceType", "USER").withValue("audienceId", "reader-2"))).getRecords().get(0); + assertThat(share.getErrors()).isNullOrEmpty(); + + login("reader-2"); + assertThat(QueryAction.execute(SqlWorkbenchNotebook.TABLE_NAME, new QQueryFilter())).extracting(r -> r.getValueInteger("id")).containsExactly(both); + assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebook.TABLE_NAME)).getCount()).isEqualTo(1); + assertThat(QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter())).hasSize(1); + assertThat(new CountAction().execute(new CountInput(SqlWorkbenchCell.TABLE_NAME)).getCount()).isEqualTo(1); + assertThat(QueryAction.execute(SqlWorkbenchNotebookShare.TABLE_NAME, new QQueryFilter())).hasSize(2); + assertThat(new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME).withRecord(new QRecord().withValue("notebookId", both).withValue("content", "x"))) + .getRecords().get(0).getErrors()).isNotEmpty(); + assertThat(new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME).withRecord(new QRecord().withValue("notebookId", personal).withValue("content", "x"))) + .getRecords().get(0).getErrors()).isNotEmpty(); + + login("admin-1"); + assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebook.TABLE_NAME)).getCount()).isEqualTo(2); + + login("writer-1"); + notebooks.delete(both, writer()); + assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebookShare.TABLE_NAME)).getCount()).isZero(); + } + + + + /******************************************************************************* + ** Log in with the workbench security keys. + *******************************************************************************/ + private void login(String userId) + { + QSession session = BaseSqlWorkbenchTest.TestSessionCustomizer.sessionFor(userId); + QContext.init(qInstance, session); + SqlWorkbenchSecurity.applyToSession(config, session, List.of()); + } + } diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java index 9c09c2a..016f8b5 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java @@ -58,33 +58,53 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest assertThat(q.getArtifactId()).isEqualTo("sql-workbench"); }); - for(String name : List.of("sqlWorkbenchNotebook", "sqlWorkbenchCell", "sqlWorkbenchNotebookVersion", "sqlWorkbenchResultSnapshot", "sqlWorkbenchExecution")) + //////////////////////////////////////////////////////////////////////////// + // name -> {insert, update, delete} capabilities: notebooks, cells and // + // shares are fully editable; pinned results can be relabeled / deleted // + // but only pinned from the notebook UI; versions and the audit are // + // append-only. Every table is a standard per-table-permission table // + // with a record security lock, and none is hidden. // + //////////////////////////////////////////////////////////////////////////// + Map> capabilities = new java.util.LinkedHashMap<>(); + capabilities.put("sqlWorkbenchNotebook", List.of(true, true, true)); + capabilities.put("sqlWorkbenchCell", List.of(true, true, true)); + capabilities.put("sqlWorkbenchNotebookShare", List.of(true, true, true)); + capabilities.put("sqlWorkbenchResultSnapshot", List.of(false, true, true)); + capabilities.put("sqlWorkbenchNotebookVersion", List.of(false, false, false)); + capabilities.put("sqlWorkbenchExecution", List.of(false, false, false)); + for(Map.Entry> entry : capabilities.entrySet()) { + String name = entry.getKey(); QTableMetaData table = qInstance.getTable(name); assertThat(table).as(name).isNotNull(); assertThat(table.getBackendName()).isEqualTo(BACKEND_NAME); - assertThat(table.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION); - assertThat(table.getPermissionRules().getPermissionBaseName()).as("tables need the admin permission, not plain access").isEqualTo("sqlWorkbenchAdmin"); - boolean writable = name.equals("sqlWorkbenchNotebook"); - assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).as(name).isEqualTo(writable); - assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE)).as(name).isEqualTo(writable); - assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)).as(name).isEqualTo(writable); + assertThat(table.getIsHidden()).as(name).isFalse(); + assertThat(table.getPermissionRules().getLevel()).as(name).isEqualTo(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS); + assertThat(table.getRecordSecurityLocks()).as(name).isNotEmpty(); + com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData backend = qInstance.getBackend(BACKEND_NAME); + assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).as(name + " insert").isEqualTo(entry.getValue().get(0)); + assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE)).as(name + " update").isEqualTo(entry.getValue().get(1)); + assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)).as(name + " delete").isEqualTo(entry.getValue().get(2)); + assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_EXPORT)).as(name + " export").isTrue(); assertThat(((RDBMSTableBackendDetails) table.getBackendDetails()).getTableName()).startsWith("sql_workbench_"); assertThat(table.getSourceQBitName()).isNotBlank(); } + assertThat(qInstance.getSecurityKeyType("sqlWorkbenchUser").getAllAccessKeyName()).isEqualTo("sqlWorkbenchAllAccess"); + assertThat(qInstance.getSecurityKeyType("sqlWorkbenchAudience")).isNotNull(); QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(SqlWorkbenchWidgetMetaDataProducer.NAME); assertThat(widget.getType()).isEqualTo("SQL_WORKBENCH"); QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME); assertThat(app.getWidgets()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME); - assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME); + assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("sqlWorkbenchNotebook", "sqlWorkbenchCell", "sqlWorkbenchNotebookShare", + "sqlWorkbenchResultSnapshot", "sqlWorkbenchNotebookVersion", "sqlWorkbenchExecution"); assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sqlWorkbench"); - ////////////////////////////////////////////////////////////////// - // the qbit produces no processes (only QQQ's generic bulk ones // - // are added to its tables by the enricher) // - ////////////////////////////////////////////////////////////////// - assertThat(qInstance.getProcesses() == null ? Map.of() : qInstance.getProcesses()).allSatisfy((name, process) -> assertThat(name).contains(".")); + /////////////////////////////////////////////////////////////////// + // the qbit's own processes are record actions on its tables and // + // never app children (the nav only lists tables and the widget) // + /////////////////////////////////////////////////////////////////// + assertThat(app.getChildren()).allSatisfy(child -> assertThat(child).isInstanceOf(QTableMetaData.class)); assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty(); assertThat(SqlWorkbenchRuntime.get(qInstance)).isNotNull(); diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/engine/SqlExecutionEngineTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/engine/SqlExecutionEngineTest.java index 4bf68a8..71bbaf0 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/engine/SqlExecutionEngineTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/engine/SqlExecutionEngineTest.java @@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest; +import com.kingsrook.qbits.sqlworkbench.SystemUser; import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; import org.junit.jupiter.api.BeforeEach; @@ -73,7 +74,7 @@ class SqlExecutionEngineTest extends BaseSqlWorkbenchTest *******************************************************************************/ private List audits() throws Exception { - return (QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter())); + return (SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()))); } diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableAccessTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableAccessTest.java deleted file mode 100644 index 311fa9b..0000000 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableAccessTest.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * 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.metadata; - - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; -import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; -import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; -import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest; -import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; -import com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchApi; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; -import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; -import com.kingsrook.qqq.middleware.javalin.executors.TableQueryExecutor; -import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryInput; -import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryOutputInterface; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - - -/******************************************************************************* - ** The qbit's tables are not reachable through the generic QQQ table API by - ** ordinary workbench users (only by holders of the admin permission), while - ** the workbench's own endpoints keep working for them. - *******************************************************************************/ -class TableAccessTest extends BaseSqlWorkbenchTest -{ - private static final List TABLES = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, - SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME); - - - - /******************************************************************************* - ** Query a table the way /qqq/v1/table/{table}/query does. - *******************************************************************************/ - private static List tableApiQuery(String tableName) throws Exception - { - List records = new ArrayList<>(); - TableQueryInput input = new TableQueryInput(); - input.setTableName(tableName); - new TableQueryExecutor().execute(input, (TableQueryOutputInterface) records::addAll); - return (records); - } - - - - /******************************************************************************* - ** A writer's personal notebook (with a cell, a version and a pinned - ** snapshot) and an audited execution. - *******************************************************************************/ - private Integer seedWriterData(SqlWorkbenchApi api) throws Exception - { - loginAs(WRITER); - Map notebook = api.notebooks().create(Map.of("title", "Private payroll", "cells", - List.of(Map.of("cellKey", "a", "cellType", "SQL", "content", "SELECT salary FROM person"))), caller(WRITER)); - Integer id = (Integer) notebook.get("id"); - - Map response = api.execute(caller(WRITER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT first_name, salary FROM person ORDER BY id")); - assertThat(response.get("status")).isEqualTo("SUCCEEDED"); - api.createSnapshot(caller(WRITER), id, Map.of("executionUuid", response.get("executionUuid"), "cellKey", "a")); - return (id); - } - - - - /******************************************************************************* - ** Another ordinary user cannot read the writer's personal notebook, cells, - ** versions, snapshot or audit row through the generic table API, nor - ** write any of those tables; the owner cannot either (no generic access - ** at all). The workbench API still hides the notebook from the other user. - *******************************************************************************/ - @Test - void testOrdinaryUsersHaveNoGenericTableAccess() throws Exception - { - SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance)); - Integer id = seedWriterData(api); - - for(String user : List.of(OTHER_READER, WRITER)) - { - loginAs(user); - for(String table : TABLES) - { - assertThatThrownBy(() -> tableApiQuery(table)).as(user + " querying " + table).isInstanceOf(QPermissionDeniedException.class); - for(TablePermissionSubType subType : List.of(TablePermissionSubType.READ, TablePermissionSubType.INSERT, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE)) - { - assertThat(PermissionsHelper.hasTablePermission(new QueryInput(table), table, subType)).as(user + " " + subType + " " + table).isFalse(); - } - } - } - - /////////////////////////////////////////////////////////////////// - // through the workbench API the notebook does not exist for the // - // other reader, and their history holds none of the writer's // - /////////////////////////////////////////////////////////////////// - loginAs(OTHER_READER); - assertThatThrownBy(() -> api.notebooks().get(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class); - assertThatThrownBy(() -> api.notebooks().snapshots(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class); - assertThat(api.notebooks().list("mine", caller(OTHER_READER))).isEmpty(); - assertThat(api.notebooks().list("shared", caller(OTHER_READER))).isEmpty(); - assertThat((List) api.history(caller(OTHER_READER), 50, null).get("executions")).isEmpty(); - } - - - - /******************************************************************************* - ** The workbench's own endpoints work for an ordinary user (session without - ** the admin permission): notebooks, versions, snapshots, history. - *******************************************************************************/ - @Test - void testWorkbenchApiStillWorksForOrdinaryUsers() throws Exception - { - SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance)); - Integer id = seedWriterData(api); - - loginAs(WRITER); - assertThat(api.notebooks().get(id, caller(WRITER)).get("title")).isEqualTo("Private payroll"); - assertThat(api.notebooks().update(id, Map.of("title", "Payroll"), caller(WRITER)).get("currentVersion")).isEqualTo(2); - assertThat(api.notebooks().versions(id, caller(WRITER))).hasSize(2); - assertThat(api.notebooks().snapshots(id, caller(WRITER))).hasSize(1); - assertThat(api.notebooks().list("mine", caller(WRITER))).hasSize(1); - assertThat((List) api.history(caller(WRITER), 50, null).get("executions")).hasSize(1); - - api.notebooks().publish(id, true, caller(WRITER)); - loginAs(READER); - assertThat(api.notebooks().get(id, caller(READER)).get("canEdit")).isEqualTo(false); - assertThat(api.notebooks().duplicate(id, caller(READER)).get("ownerUserId")).isEqualTo(READER); - - loginAs(WRITER); - api.notebooks().delete(id, caller(WRITER)); - assertThat(api.notebooks().list("mine", caller(WRITER))).isEmpty(); - } - - - - /******************************************************************************* - ** A holder of the admin permission can read everything through the - ** generic table API. - *******************************************************************************/ - @Test - void testAdminHasGenericTableAccess() throws Exception - { - SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance)); - seedWriterData(api); - - loginAs(ADMIN); - assertThat(tableApiQuery(SqlWorkbenchNotebook.TABLE_NAME)).hasSize(1); - assertThat(tableApiQuery(SqlWorkbenchCell.TABLE_NAME)).hasSize(1); - assertThat(tableApiQuery(SqlWorkbenchNotebookVersion.TABLE_NAME)).hasSize(1); - assertThat(tableApiQuery(SqlWorkbenchResultSnapshot.TABLE_NAME)).hasSize(1); - assertThat(tableApiQuery(SqlWorkbenchExecution.TABLE_NAME)).hasSize(1); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java new file mode 100644 index 0000000..0c95692 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java @@ -0,0 +1,910 @@ +/* + * 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.metadata; + + +import java.io.InputStream; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.SystemUser; +import com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchApi; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; +import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot; +import com.kingsrook.qbits.sqlworkbench.processes.SqlWorkbenchRecordActionSteps; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.middleware.javalin.executors.TableCountExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableDeleteExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableExportExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableGetExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableInsertExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableQueryExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.TableUpdateExecutor; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableCountInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableCountOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableDeleteInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableDeleteOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableExportInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableExportOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableGetInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableGetOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableInsertInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableInsertOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryOutputInterface; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableUpdateInput; +import com.kingsrook.qqq.middleware.javalin.executors.io.TableUpdateOutputInterface; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The workbench tables' security, role x table x action: who sees which + ** rows through the /qqq/v1 table API (query, count, get, export), the core + ** actions, PVS lookups and record actions, and who may write them. + ** + ** Fixture (all created by OWNER through the notebook endpoints or tables): + ** - "Private": personal, one cell, one pinned result + ** - "For reader-3": shared with the user reader-3 + ** - "For analysts": shared with the group Analysts (reader-4 is a member) + ** - "Library": published to the shared library (everyone) + ** - "Other's own": OTHER's personal notebook + ** Every notebook has one cell and (at least) one version. + *******************************************************************************/ +class TableSecurityMatrixTest extends BaseSqlWorkbenchTest +{ + private static final String OWNER = WRITER; + private static final String OTHER = OTHER_READER; + private static final String SHARED_USER = "reader-3"; + private static final String GROUP_MEMBER = "reader-4"; + private static final String GROUP = "Analysts"; + private static final String OTHER_SHARER = "writer-2"; + private static final String OUTSIDER = "nobody-1"; + + private static final List ALL_TITLES = List.of("Private", "For reader-3", "For analysts", "Library", "Other's own"); + + private SqlWorkbenchApi api; + private Map ids = new LinkedHashMap<>(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void seed() throws Exception + { + api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance)); + for(String title : List.of("Private", "For reader-3", "For analysts", "Library")) + { + ids.put(title, notebook(OWNER, title)); + } + ids.put("Other's own", notebook(OTHER, "Other's own")); + + loginAs(OWNER); + insertShare(ids.get("For reader-3"), "USER", SHARED_USER); + tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", ids.get("For analysts"), "audienceType", "GROUP", "audienceId", GROUP)); + api.notebooks().publish(ids.get("Library"), true, api.caller(null)); + + Map run = api.execute(as(OWNER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT first_name, salary FROM person ORDER BY id")); + api.createSnapshot(as(OWNER), ids.get("Private"), Map.of("executionUuid", run.get("executionUuid"), "cellKey", "c1")); + api.execute(as(OTHER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1")); + } + + + + /******************************************************************************* + ** Create a notebook with one cell through the notebook endpoint. + *******************************************************************************/ + private Integer notebook(String user, String title) throws Exception + { + return ((Integer) api.notebooks().create(Map.of("title", title, "cells", List.of(Map.of("cellKey", "c1", "cellType", "SQL", "content", "SELECT 1"))), as(user)).get("id")); + } + + + + /******************************************************************************* + ** Log in as a user (the group member with their group). + *******************************************************************************/ + private void login(String user) + { + if(GROUP_MEMBER.equals(user)) + { + loginAs(user, GROUP); + } + else + { + loginAs(user); + } + } + + + + /******************************************************************************* + ** The notebooks each user may read. + *******************************************************************************/ + private static Set readableTitles(String user) + { + return (new TreeSet<>(switch(user) + { + case OWNER -> List.of("Private", "For reader-3", "For analysts", "Library"); + case OTHER -> List.of("Library", "Other's own"); + case SHARED_USER -> List.of("For reader-3", "Library"); + case GROUP_MEMBER -> List.of("For analysts", "Library"); + case OTHER_SHARER -> List.of("Library"); + case ADMIN -> ALL_TITLES; + default -> List.of(); + })); + } + + + + /******************************************************************************* + ** Reads: v1 query / count / get / export, core query, and PVS lookups + ** agree for every role on every table. + *******************************************************************************/ + @Test + void testReadMatrix() throws Exception + { + for(String user : List.of(OWNER, OTHER, SHARED_USER, GROUP_MEMBER, OTHER_SHARER, ADMIN)) + { + login(user); + Set titles = readableTitles(user); + int readable = titles.size(); + + ////////////////////////////////////////////// + // notebooks: v1 query, count, export, get // + ////////////////////////////////////////////// + assertThat(new TreeSet<>(v1Query(SqlWorkbenchNotebook.TABLE_NAME).stream().map(r -> r.getValueString("title")).toList())).as(user + " v1 query notebooks").isEqualTo(titles); + assertThat(v1Count(SqlWorkbenchNotebook.TABLE_NAME)).as(user + " v1 count notebooks").isEqualTo(readable); + assertThat(v1ExportRows(SqlWorkbenchNotebook.TABLE_NAME)).as(user + " v1 export notebooks").isEqualTo(readable); + for(String title : ALL_TITLES) + { + Integer id = ids.get(title); + if(titles.contains(title)) + { + assertThat(v1Get(SqlWorkbenchNotebook.TABLE_NAME, id).getValueString("title")).as(user + " v1 get " + title).isEqualTo(title); + assertThat(new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebook.TABLE_NAME).withPrimaryKey(id))).as(user + " core get " + title).isNotNull(); + } + else + { + assertThatThrownBy(() -> v1Get(SqlWorkbenchNotebook.TABLE_NAME, id)).as(user + " v1 get " + title).isInstanceOf(QNotFoundException.class); + assertThat(new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebook.TABLE_NAME).withPrimaryKey(id))).as(user + " core get " + title).isNull(); + } + } + + ///////////////////////////////////////////////////////////////////////// + // children follow their notebook: one cell and one version per // + // readable notebook; only "Private" has a pinned result // + ///////////////////////////////////////////////////////////////////////// + assertThat(v1Count(SqlWorkbenchCell.TABLE_NAME)).as(user + " cells").isEqualTo(readable); + assertThat(v1Query(SqlWorkbenchNotebookVersion.TABLE_NAME)).as(user + " versions").extracting(r -> r.getValueInteger("notebookId")) + .containsExactlyInAnyOrderElementsOf(titles.stream().map(ids::get).toList()); + assertThat(v1Count(SqlWorkbenchResultSnapshot.TABLE_NAME)).as(user + " snapshots").isEqualTo(titles.contains("Private") ? 1 : 0); + assertThat(v1ExportRows(SqlWorkbenchCell.TABLE_NAME)).as(user + " export cells").isEqualTo(readable); + + //////////////////////////////////////////////////////////////////// + // shares: the owner sees their notebooks' shares, an audience // + // sees the shares that name it, admins see all // + //////////////////////////////////////////////////////////////////// + List shareKeys = v1Query(SqlWorkbenchNotebookShare.TABLE_NAME).stream().map(r -> r.getValueString("audienceKey")).sorted().toList(); + assertThat(shareKeys).as(user + " shares").isEqualTo(switch(user) + { + case OWNER, ADMIN -> List.of("all", "group:analysts", "user:reader-3"); + case SHARED_USER -> List.of("all", "user:reader-3"); + case GROUP_MEMBER -> List.of("all", "group:analysts"); + default -> List.of("all"); + }); + + ///////////////////////////////////////////////// + // the audit: own executions; admins see all // + ///////////////////////////////////////////////// + assertThat(v1Query(SqlWorkbenchExecution.TABLE_NAME)).as(user + " executions").extracting(r -> r.getValueString("userId")) + .containsExactlyInAnyOrderElementsOf(switch(user) + { + case OWNER -> List.of(OWNER); + case OTHER -> List.of(OTHER); + case ADMIN -> List.of(OWNER, OTHER); + default -> List.of(); + }); + + //////////////////////////////////////////////////////////////// + // notebook PVS lookups (labels of notebookId fields) only // + // show readable notebooks // + //////////////////////////////////////////////////////////////// + List labels = new ArrayList<>(); + new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(SqlWorkbenchNotebook.TABLE_NAME).withSearchTerm("")) + .getResults().forEach(pv -> labels.add(pv.getLabel())); + assertThat(new TreeSet<>(labels)).as(user + " notebook PVS").isEqualTo(titles); + } + } + + + + /******************************************************************************* + ** Users without workbench access: no table permission (v1 refuses), and + ** no security keys (the core actions find nothing, not even the library). + *******************************************************************************/ + @Test + void testOutsider() throws Exception + { + loginAs(OUTSIDER); + for(String table : AppTables.ALL) + { + assertThatThrownBy(() -> v1Query(table)).as(table).isInstanceOf(QPermissionDeniedException.class); + for(TablePermissionSubType subType : TablePermissionSubType.values()) + { + assertThat(PermissionsHelper.hasTablePermission(new QueryInput(table), table, subType)).as(subType + " " + table).isFalse(); + } + assertThat(new CountAction().execute(new CountInput(table)).getCount()).as("core count " + table).isZero(); + } + } + + + + /******************************************************************************* + ** Writes: only the owner (or an admin) changes a notebook or its children; + ** being shared with, or seeing it in the library, is read-only. + *******************************************************************************/ + @Test + void testWriteMatrix() throws Exception + { + Integer privateId = ids.get("Private"); + Integer userShared = ids.get("For reader-3"); + Integer groupShared = ids.get("For analysts"); + Integer library = ids.get("Library"); + + ////////////////////////////////////////////////////////////// + // readable but not theirs: update / delete / child writes // + ////////////////////////////////////////////////////////////// + login(SHARED_USER); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, userShared, Map.of("title", "hijacked"))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchNotebook.TABLE_NAME, userShared)).isZero(); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchCell.TABLE_NAME, Map.of("notebookId", userShared, "content", "SELECT 2"))).isInstanceOf(QException.class); + Integer sharedCell = cellOf(userShared); + assertThatThrownBy(() -> v1Update(SqlWorkbenchCell.TABLE_NAME, sharedCell, Map.of("content", "DROP TABLE x"))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchCell.TABLE_NAME, sharedCell)).isZero(); + + login(GROUP_MEMBER); + Integer groupCell = cellOf(groupShared); + assertThatThrownBy(() -> v1Update(SqlWorkbenchCell.TABLE_NAME, groupCell, Map.of("content", "x"))).isInstanceOf(QException.class); + + login(OTHER); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, library, Map.of("title", "hijacked"))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchNotebook.TABLE_NAME, library)).isZero(); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, privateId, Map.of("title", "hijacked"))).isInstanceOf(QException.class); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchCell.TABLE_NAME, Map.of("notebookId", privateId, "content", "SELECT 2"))).isInstanceOf(QException.class); + + //////////////////////////////////////////////////////////////////////////// + // sharing: needs the share permission, and only for one's own notebooks // + //////////////////////////////////////////////////////////////////////////// + assertThatThrownBy(() -> tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", library, "audienceType", "USER", "audienceId", OTHER))) + .isInstanceOf(QPermissionDeniedException.class); + login(OTHER_SHARER); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", library, "audienceType", "USER", "audienceId", OTHER_SHARER))) + .isInstanceOf(QException.class); + Integer libraryShare = shareOf(library); + assertThat(v1Delete(SqlWorkbenchNotebookShare.TABLE_NAME, libraryShare)).isZero(); + + ////////////////////////////////////////////////////////////// + // inserting a notebook "for" someone else makes it yours // + ////////////////////////////////////////////////////////////// + login(OTHER); + QRecord forged = tableInsert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Forged", "ownerUserId", OWNER, "visibility", "SHARED")); + assertThat(forged.getValueString("ownerUserId")).isEqualTo(OTHER); + assertThat(forged.getValueString("visibility")).isEqualTo("PERSONAL"); + + /////////////////////////////////////////////////// + // nothing above changed the owner's notebooks // + /////////////////////////////////////////////////// + List stored = SystemUser.call(() -> QueryAction.execute(SqlWorkbenchNotebook.TABLE_NAME, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of(privateId, userShared, groupShared, library))))); + assertThat(stored).extracting(r -> r.getValueString("title")).containsExactlyInAnyOrder("Private", "For reader-3", "For analysts", "Library"); + assertThat(SystemUser.call(() -> QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter()))).extracting(r -> r.getValueString("content")).containsOnly("SELECT 1"); + + ////////////////////////////////////////////////////// + // the owner, and an admin, may change the notebook // + ////////////////////////////////////////////////////// + login(OWNER); + assertThat(v1Update(SqlWorkbenchNotebook.TABLE_NAME, privateId, Map.of("title", "Private v2")).getValueString("title")).isEqualTo("Private v2"); + login(ADMIN); + assertThat(v1Update(SqlWorkbenchNotebook.TABLE_NAME, privateId, Map.of("title", "Private v3")).getValueString("title")).isEqualTo("Private v3"); + assertThat(v1Get(SqlWorkbenchNotebook.TABLE_NAME, privateId).getValueString("ownerUserId")).isEqualTo(OWNER); + } + + + + /******************************************************************************* + ** Versions and the audit are append-only for everyone - even an admin + ** holding (ad hoc) write permissions on them - and pinned results can + ** only be created by pinning, and only relabeled. + *******************************************************************************/ + @Test + void testAppendOnlyAndPinnedResults() throws Exception + { + for(String user : List.of(OWNER, ADMIN)) + { + login(user); + for(String table : List.of(SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME)) + { + QContext.getQSession().withPermission(table + ".insert").withPermission(table + ".edit").withPermission(table + ".delete"); + Integer id = v1Query(table).get(0).getValueInteger("id"); + assertThatThrownBy(() -> v1Update(table, id, Map.of("comment", "rewritten", "sqlText", "rewritten"))).as(user + " update " + table).isInstanceOf(QException.class); + assertThat(v1Delete(table, id)).as(user + " delete " + table).isZero(); + assertThatThrownBy(() -> tableInsert(table, Map.of("notebookId", ids.get("Private"), "versionNumber", 99, "executionUuid", "x"))).as(user + " insert " + table).isInstanceOf(QException.class); + } + } + assertThat(SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, new QQueryFilter()))).hasSize(2); + + login(OWNER); + QContext.getQSession().withPermission(SqlWorkbenchResultSnapshot.TABLE_NAME + ".insert"); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchResultSnapshot.TABLE_NAME, Map.of("notebookId", ids.get("Private"), "resultJson", "{\"forged\":true}"))) + .isInstanceOf(QException.class).hasMessageContaining("pinned from the notebook"); + Integer snapshotId = v1Query(SqlWorkbenchResultSnapshot.TABLE_NAME).get(0).getValueInteger("id"); + assertThat(v1Update(SqlWorkbenchResultSnapshot.TABLE_NAME, snapshotId, Map.of("label", "Payroll")).getValueString("label")).isEqualTo("Payroll"); + assertThatThrownBy(() -> v1Update(SqlWorkbenchResultSnapshot.TABLE_NAME, snapshotId, Map.of("rowCount", 999))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchResultSnapshot.TABLE_NAME, snapshotId)).isEqualTo(1); + } + + + + /******************************************************************************* + ** Table edits keep the same invariants as the notebook UI: ordering, + ** keys, versions, library visibility, cascades. + *******************************************************************************/ + @Test + @SuppressWarnings("unchecked") + void testTableEditsKeepNotebookInvariants() throws Exception + { + login(OWNER); + Integer id = ids.get("Private"); + assertThat(currentVersion(id)).isEqualTo(1); + + //////////////////////////////////////////////////////////////////////// + // a cell added through the table goes last, gets a key, and appends // + // a version; the notebook endpoint shows it // + //////////////////////////////////////////////////////////////////////// + QRecord cell = tableInsert(SqlWorkbenchCell.TABLE_NAME, Map.of("notebookId", id, "cellType", "markdown", "content", "# Notes")); + assertThat(cell.getValueInteger("sortOrder")).isEqualTo(1); + assertThat(cell.getValueString("cellKey")).isNotBlank(); + assertThat(cell.getValueString("cellType")).isEqualTo("MARKDOWN"); + assertThat(currentVersion(id)).isEqualTo(2); + List> cells = (List>) api.notebooks().get(id, as(OWNER)).get("cells"); + assertThat(cells).extracting(c -> c.get("content")).containsExactly("SELECT 1", "# Notes"); + + v1Update(SqlWorkbenchCell.TABLE_NAME, cell.getValueInteger("id"), Map.of("content", "# More notes")); + assertThat(currentVersion(id)).isEqualTo(3); + assertThat(api.notebooks().version(id, 3, as(OWNER)).get("notebook").toString()).contains("# More notes"); + assertThatThrownBy(() -> v1Update(SqlWorkbenchCell.TABLE_NAME, cell.getValueInteger("id"), Map.of("notebookId", ids.get("Library")))).isInstanceOf(QException.class); + assertThatThrownBy(() -> v1Update(SqlWorkbenchCell.TABLE_NAME, cell.getValueInteger("id"), Map.of("cellType", "PYTHON"))).isInstanceOf(QException.class); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchCell.TABLE_NAME, Map.of("notebookId", id, "content", "x".repeat(config.getMaxSqlLength() + 1)))).isInstanceOf(QException.class); + + assertThat(v1Delete(SqlWorkbenchCell.TABLE_NAME, cell.getValueInteger("id"))).isEqualTo(1); + assertThat(currentVersion(id)).isEqualTo(4); + + /////////////////////////////////////////////////////////////// + // notebook edits through the table: versioned; visibility // + // and system fields cannot be set directly // + /////////////////////////////////////////////////////////////// + v1Update(SqlWorkbenchNotebook.TABLE_NAME, id, Map.of("description", "via the table", "currentVersion", 99)); + assertThat(currentVersion(id)).isEqualTo(5); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, id, Map.of("visibility", "SHARED"))).isInstanceOf(QException.class); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, id, Map.of("title", " "))).isInstanceOf(QException.class); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, id, Map.of("parametersJson", "{not a list"))).isInstanceOf(QException.class); + + QRecord created = tableInsert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Made in the table")); + assertThat(created.getValueInteger("currentVersion")).isEqualTo(0); + assertThat(currentVersion(created.getValueInteger("id"))).isEqualTo(1); + + //////////////////////////////////////////////////////////////////// + // publishing through the share table = publishing in the UI // + //////////////////////////////////////////////////////////////////// + QRecord share = tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", id, "audienceType", "all", "audienceId", "ignored")); + assertThat(share.getValueString("audienceKey")).isEqualTo("all"); + assertThat(share.getValueString("audienceId")).isNull(); + assertThat(api.notebooks().get(id, as(OWNER)).get("visibility")).isEqualTo("SHARED"); + assertThat(api.notebooks().list("shared", as(OTHER))).extracting(n -> n.get("id")).contains(id); + login(OWNER); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", id, "audienceType", "ALL"))).isInstanceOf(QException.class); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", id, "audienceType", "USER"))).isInstanceOf(QException.class); + assertThatThrownBy(() -> tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", id, "audienceType", "EVERYONE"))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchNotebookShare.TABLE_NAME, share.getValueInteger("id"))).isEqualTo(1); + assertThat(api.notebooks().get(id, as(OWNER)).get("visibility")).isEqualTo("PERSONAL"); + + //////////////////////////////////////////////////////////////// + // a user share with different case still reaches the user // + //////////////////////////////////////////////////////////////// + login(OWNER); + tableInsert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", id, "audienceType", "USER", "audienceId", " READER-2 ")); + assertThat(api.notebooks().get(id, as(OTHER)).get("canEdit")).isEqualTo(false); + + //////////////////////////////////////////////////////////////// + // deleting a notebook through the table removes its children // + //////////////////////////////////////////////////////////////// + login(OWNER); + assertThat(v1Delete(SqlWorkbenchNotebook.TABLE_NAME, id)).isEqualTo(1); + for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME)) + { + assertThat(SystemUser.call(() -> QueryAction.execute(child, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, id))))).as(child).isEmpty(); + } + assertThat(SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, new QQueryFilter()))).as("the audit survives").hasSize(2); + } + + + + /******************************************************************************* + ** Editing a share re-targets it (new audience key, visibility in step); + ** audiences cannot edit or remove the share that names them. + *******************************************************************************/ + @Test + void testShareEdits() throws Exception + { + Integer notebookId = ids.get("For reader-3"); + Integer shareId = shareOf(notebookId); + + login(OWNER); + QRecord retargeted = v1Update(SqlWorkbenchNotebookShare.TABLE_NAME, shareId, Map.of("audienceId", GROUP_MEMBER, "audienceKey", "all", "createdByUserId", "forged")); + assertThat(retargeted.getValueString("audienceKey")).isEqualTo("user:reader-4"); + login(GROUP_MEMBER); + assertThat(v1Get(SqlWorkbenchNotebook.TABLE_NAME, notebookId).getValueString("title")).isEqualTo("For reader-3"); + login(SHARED_USER); + assertThatThrownBy(() -> v1Get(SqlWorkbenchNotebook.TABLE_NAME, notebookId)).isInstanceOf(QNotFoundException.class); + + login(OWNER); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebookShare.TABLE_NAME, shareId, Map.of("notebookId", ids.get("Private")))).isInstanceOf(QException.class); + v1Update(SqlWorkbenchNotebookShare.TABLE_NAME, shareId, Map.of("audienceType", "ALL")); + assertThat(api.notebooks().get(notebookId, as(OWNER)).get("visibility")).isEqualTo("SHARED"); + login(OWNER); + v1Update(SqlWorkbenchNotebookShare.TABLE_NAME, shareId, Map.of("audienceType", "USER", "audienceId", SHARED_USER)); + assertThat(api.notebooks().get(notebookId, as(OWNER)).get("visibility")).isEqualTo("PERSONAL"); + assertThat(SystemUser.call(() -> new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebookShare.TABLE_NAME).withPrimaryKey(shareId))).getValueString("createdByUserId")).isEqualTo(OWNER); + + //////////////////////////////////////////////////////////////////////// + // the audience can read its share row, but not change or remove it, // + // even through the core actions (no table-permission check there) // + //////////////////////////////////////////////////////////////////////// + login(SHARED_USER); + assertThat(v1Get(SqlWorkbenchNotebookShare.TABLE_NAME, shareId).getValueString("audienceKey")).isEqualTo("user:reader-3"); + QRecord coreUpdate = new UpdateAction().execute(new UpdateInput(SqlWorkbenchNotebookShare.TABLE_NAME) + .withRecord(new QRecord().withValue("id", shareId).withValue("audienceId", OTHER))).getRecords().get(0); + assertThat(coreUpdate.getErrors()).isNotEmpty(); + DeleteOutput coreDelete = new DeleteAction().execute(new DeleteInput(SqlWorkbenchNotebookShare.TABLE_NAME).withPrimaryKeys(List.of(shareId))); + assertThat(coreDelete.getDeletedRecordCount()).isZero(); + assertThat(SystemUser.call(() -> new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebookShare.TABLE_NAME).withPrimaryKey(shareId))).getValueString("audienceKey")).isEqualTo("user:reader-3"); + } + + + + /******************************************************************************* + ** Record actions: Share, Duplicate, Restore Version - permission-gated, + ** and their records come through the locks. + *******************************************************************************/ + @Test + void testRecordActions() throws Exception + { + Integer privateId = ids.get("Private"); + Integer library = ids.get("Library"); + + login(OWNER); + RunProcessOutput shared = runRecordAction(SqlWorkbenchRecordActionsMetaDataProducer.SHARE_NOTEBOOK, privateId, Map.of("audienceType", "USER", "audienceId", SHARED_USER)); + assertThat(shared.getException()).isEmpty(); + assertThat(shared.getValue(SqlWorkbenchRecordActionSteps.RESULT_MESSAGE).toString()).contains("user:reader-3"); + login(SHARED_USER); + assertThat(v1Get(SqlWorkbenchNotebook.TABLE_NAME, privateId).getValueString("title")).isEqualTo("Private"); + + /////////////////////////////////////////////////////////////////////// + // a reader without share permission cannot see or run Share; a // + // record they cannot read is simply not found by the action // + /////////////////////////////////////////////////////////////////////// + login(OTHER); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), SqlWorkbenchRecordActionsMetaDataProducer.SHARE_NOTEBOOK)).isFalse(); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), SqlWorkbenchRecordActionsMetaDataProducer.DUPLICATE_NOTEBOOK)).isTrue(); + RunProcessOutput copy = runRecordAction(SqlWorkbenchRecordActionsMetaDataProducer.DUPLICATE_NOTEBOOK, library, Map.of()); + assertThat(copy.getException()).isEmpty(); + assertThat(api.notebooks().list("mine", as(OTHER))).extracting(n -> n.get("title")).contains("Copy of Library"); + login(OTHER); + assertRecordActionFails(SqlWorkbenchRecordActionsMetaDataProducer.DUPLICATE_NOTEBOOK, privateId, "Too few records"); + + /////////////////////////////////////////////////////// + // restore: the owner, as a new version; not others // + /////////////////////////////////////////////////////// + login(OWNER); + api.notebooks().update(privateId, Map.of("title", "Private v2"), as(OWNER)); + login(OWNER); + Integer v1 = SystemUser.call(() -> QueryAction.execute(SqlWorkbenchNotebookVersion.TABLE_NAME, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, privateId)) + .withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, 1)))).get(0).getValueInteger("id"); + RunProcessOutput restored = runRecordAction(SqlWorkbenchRecordActionsMetaDataProducer.RESTORE_VERSION, v1, Map.of()); + assertThat(restored.getException()).isEmpty(); + assertThat(v1Get(SqlWorkbenchNotebook.TABLE_NAME, privateId).getValueString("title")).isEqualTo("Private"); + assertThat(currentVersion(privateId)).isEqualTo(3); + login(SHARED_USER); + assertRecordActionFails(SqlWorkbenchRecordActionsMetaDataProducer.RESTORE_VERSION, v1, "Only the notebook's owner"); + } + + + + /******************************************************************************* + ** The notebook endpoints see exactly what the tables show. + *******************************************************************************/ + @Test + void testNotebookEndpointsMatchTheTables() throws Exception + { + for(String user : List.of(OWNER, OTHER, SHARED_USER, GROUP_MEMBER, ADMIN)) + { + login(user); + Set fromTables = new TreeSet<>(v1Query(SqlWorkbenchNotebook.TABLE_NAME).stream().map(r -> r.getValueString("title")).toList()); + login(user); + List> mine = api.notebooks().list("mine", api.caller(null)); + List> shared = api.notebooks().list("shared", api.caller(null)); + Set seen = new TreeSet<>(); + mine.forEach(n -> seen.add((String) n.get("title"))); + shared.forEach(n -> seen.add((String) n.get("title"))); + if(ADMIN.equals(user)) + { + ////////////////////////////////////////////////////////////////// + // admins read every notebook through the tables; the notebook // + // UI still lists only their own and what is shared with them // + ////////////////////////////////////////////////////////////////// + assertThat(seen).isEqualTo(new TreeSet<>(List.of("Library"))); + assertThat(fromTables).isEqualTo(new TreeSet<>(ALL_TITLES)); + } + else + { + assertThat(seen).as(user).isEqualTo(fromTables); + } + for(String title : ALL_TITLES) + { + Integer id = ids.get(title); + login(user); + if(fromTables.contains(title)) + { + assertThat(api.notebooks().get(id, api.caller(null)).get("title")).as(user + " " + title).isEqualTo(title); + } + else + { + assertThatThrownBy(() -> api.notebooks().get(id, api.caller(null))).as(user + " " + title).isInstanceOf(QNotFoundException.class); + } + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private RunProcessOutput runRecordAction(String processName, Integer recordId, Map values) throws QException + { + RunProcessInput input = new RunProcessInput() + .withProcessName(processName) + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP) + .withValues(new LinkedHashMap<>(values)) + .withValue("recordIds", String.valueOf(recordId)); + return (new RunProcessAction().execute(input)); + } + + + + /******************************************************************************* + ** A record action that must not succeed (QQQ either throws, e.g. when the + ** locks hide the record, or returns the step's exception). + *******************************************************************************/ + private void assertRecordActionFails(String processName, Integer recordId, String message) + { + Exception failure; + try + { + failure = runRecordAction(processName, recordId, Map.of()).getException().orElse(null); + } + catch(QException e) + { + failure = e; + } + assertThat(failure).as(processName + " on " + recordId).isNotNull(); + assertThat(failure).hasMessageContaining(message); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private Integer currentVersion(Integer notebookId) throws QException + { + return (SystemUser.call(() -> QueryAction.execute(SqlWorkbenchNotebook.TABLE_NAME, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, notebookId))).get(0).getValueInteger("currentVersion"))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private Integer cellOf(Integer notebookId) throws QException + { + return (SystemUser.call(() -> QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId))).get(0).getValueInteger("id"))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private Integer shareOf(Integer notebookId) throws QException + { + return (SystemUser.call(() -> QueryAction.execute(SqlWorkbenchNotebookShare.TABLE_NAME, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId))).get(0).getValueInteger("id"))); + } + + + + /******************************************************************************* + ** Insert a share through the core action (as the session). + *******************************************************************************/ + private void insertShare(Integer notebookId, String type, String audienceId) throws QException + { + QRecord record = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebookShare.TABLE_NAME).withRecord(new QRecord() + .withValue("notebookId", notebookId).withValue("audienceType", type).withValue("audienceId", audienceId))).getRecords().get(0); + assertThat(record.getErrors()).isNullOrEmpty(); + } + + + + /******************************************************************************* + ** v1 table API: query. + *******************************************************************************/ + private static List v1Query(String tableName) throws Exception + { + List records = new ArrayList<>(); + TableQueryInput input = new TableQueryInput(); + input.setTableName(tableName); + new TableQueryExecutor().execute(input, (TableQueryOutputInterface) records::addAll); + return (records); + } + + + + /******************************************************************************* + ** v1 table API: count. + *******************************************************************************/ + private static int v1Count(String tableName) throws Exception + { + long[] count = new long[1]; + TableCountInput input = new TableCountInput(); + input.setTableName(tableName); + new TableCountExecutor().execute(input, new TableCountOutputInterface() + { + @Override + public void setCount(Long value) + { + count[0] = value; + } + + + + @Override + public void setDistinctCount(Long value) + { + } + }); + return ((int) count[0]); + } + + + + /******************************************************************************* + ** v1 table API: get. + *******************************************************************************/ + private static QRecord v1Get(String tableName, Integer id) throws Exception + { + QRecord[] record = new QRecord[1]; + TableGetInput input = new TableGetInput(); + input.setTableName(tableName); + input.setPrimaryKey(String.valueOf(id)); + new TableGetExecutor().execute(input, (TableGetOutputInterface) r -> record[0] = r); + return (record[0]); + } + + + + /******************************************************************************* + ** v1 table API: export (CSV), returning the data row count. + *******************************************************************************/ + private static int v1ExportRows(String tableName) throws Exception + { + InputStream[] stream = new InputStream[1]; + TableExportInput input = new TableExportInput(); + input.setTableName(tableName); + input.setFormat(ReportFormat.CSV.name()); + input.setFieldNames(List.of("id")); + new TableExportExecutor().execute(input, new TableExportOutputInterface() + { + @Override + public void setReportFormat(ReportFormat format) + { + } + + + + @Override + public void setFilename(String filename) + { + } + + + + @Override + public void setInputStream(InputStream inputStream) + { + stream[0] = inputStream; + } + }); + String csv = new String(stream[0].readAllBytes(), StandardCharsets.UTF_8).trim(); + return (csv.isEmpty() ? 0 : csv.split("\n").length - 1); + } + + + + /******************************************************************************* + ** v1 table API: update (throws on record errors). + *******************************************************************************/ + private static QRecord v1Update(String tableName, Integer id, Map values) throws Exception + { + QRecord[] record = new QRecord[1]; + TableUpdateInput input = new TableUpdateInput(); + input.setTableName(tableName); + input.setPrimaryKey(String.valueOf(id)); + input.setRecordValues(new LinkedHashMap<>(values)); + new TableUpdateExecutor().execute(input, new TableUpdateOutputInterface() + { + @Override + public void setRecord(QRecord r) + { + record[0] = r; + } + + + + @Override + public void setErrors(List errors) + { + } + + + + @Override + public void setWarnings(List warnings) + { + } + }); + return (record[0]); + } + + + + /******************************************************************************* + ** v1 table API: delete, returning the deleted count. + *******************************************************************************/ + private static int v1Delete(String tableName, Integer id) throws Exception + { + int[] deleted = new int[1]; + TableDeleteInput input = new TableDeleteInput(); + input.setTableName(tableName); + input.setPrimaryKey(String.valueOf(id)); + new TableDeleteExecutor().execute(input, new TableDeleteOutputInterface() + { + @Override + public void setDeletedRecordCount(Integer count) + { + deleted[0] = count == null ? 0 : count; + } + + + + @Override + public void setErrors(List errors) + { + } + }); + return (deleted[0]); + } + + + + /******************************************************************************* + ** v1 table API: insert (throws on record errors). + *******************************************************************************/ + private static QRecord tableInsert(String tableName, Map values) throws Exception + { + QRecord[] record = new QRecord[1]; + TableInsertInput input = new TableInsertInput(); + input.setTableName(tableName); + input.setRecordValues(new LinkedHashMap<>(values)); + new TableInsertExecutor().execute(input, new TableInsertOutputInterface() + { + @Override + public void setRecord(QRecord r) + { + record[0] = r; + } + + + + @Override + public void setErrors(List errors) + { + } + + + + @Override + public void setWarnings(List warnings) + { + } + }); + return (record[0]); + } + + + + /******************************************************************************* + ** The workbench table names. + *******************************************************************************/ + private static final class AppTables + { + static final List ALL = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, + SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME); + } +} diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookServiceTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookServiceTest.java index 47a374c..ca3230e 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookServiceTest.java @@ -107,7 +107,7 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest @SuppressWarnings("unchecked") void testCreateGetUpdateVersions() throws Exception { - Map created = service.create(body("People", List.of(cell("a", "MARKDOWN", "# Hi"), cell("b", "sql", "SELECT 1"))), caller(READER)); + Map created = service.create(body("People", List.of(cell("a", "MARKDOWN", "# Hi"), cell("b", "sql", "SELECT 1"))), as(READER)); Integer id = (Integer) created.get("id"); assertThat(created.get("visibility")).isEqualTo("PERSONAL"); assertThat(created.get("currentVersion")).isEqualTo(1); @@ -117,20 +117,20 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest Map update = body("People v2", List.of(cell("b", "SQL", "SELECT 2"))); update.put("comment", "trim"); - Map updated = service.update(id, update, caller(READER)); + Map updated = service.update(id, update, as(READER)); assertThat(updated.get("title")).isEqualTo("People v2"); assertThat(updated.get("currentVersion")).isEqualTo(2); assertThat((List) updated.get("cells")).hasSize(1); assertThat(QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter())).hasSize(1); - List> versions = service.versions(id, caller(READER)); + List> versions = service.versions(id, as(READER)); assertThat(versions).extracting(v -> v.get("versionNumber")).containsExactly(2, 1); assertThat(versions.get(0).get("comment")).isEqualTo("trim"); - Map v1 = service.version(id, 1, caller(READER)); + Map v1 = service.version(id, 1, as(READER)); assertThat(((Map) v1.get("notebook")).get("title")).isEqualTo("People"); - Map restored = service.restore(id, 1, caller(READER)); + Map restored = service.restore(id, 1, as(READER)); assertThat(restored.get("title")).isEqualTo("People"); assertThat(restored.get("currentVersion")).isEqualTo(3); assertThat((List) restored.get("cells")).hasSize(2); @@ -138,11 +138,11 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest //////////////////////////////////// // retention keeps the newest 3 // //////////////////////////////////// - service.update(id, Map.of("title", "People v4"), caller(READER)); + service.update(id, Map.of("title", "People v4"), as(READER)); assertThat(QueryAction.execute(SqlWorkbenchNotebookVersion.TABLE_NAME, new QQueryFilter())).hasSize(3); - assertThat(service.get(id, caller(READER)).get("cells")).asList().hasSize(2); + assertThat(service.get(id, as(READER)).get("cells")).asList().hasSize(2); - assertThatThrownBy(() -> service.version(id, 1, caller(READER))).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.version(id, 1, as(READER))).isInstanceOf(QNotFoundException.class); } @@ -153,31 +153,31 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest @Test void testVisibilityAndSharing() throws Exception { - Integer mine = (Integer) service.create(body("Mine", List.of()), caller(WRITER)).get("id"); + Integer mine = (Integer) service.create(body("Mine", List.of()), as(WRITER)).get("id"); - assertThat(service.list("mine", caller(WRITER))).hasSize(1); - assertThat(service.list("mine", caller(READER))).isEmpty(); - assertThat(service.list("shared", caller(READER))).isEmpty(); - assertThatThrownBy(() -> service.get(mine, caller(READER))).isInstanceOf(QNotFoundException.class); + assertThat(service.list("mine", as(WRITER))).hasSize(1); + assertThat(service.list("mine", as(READER))).isEmpty(); + assertThat(service.list("shared", as(READER))).isEmpty(); + assertThatThrownBy(() -> service.get(mine, as(READER))).isInstanceOf(QNotFoundException.class); - assertThatThrownBy(() -> service.publish(mine, true, caller(READER))).isInstanceOf(QPermissionDeniedException.class); - assertThat(service.publish(mine, true, caller(WRITER)).get("visibility")).isEqualTo("SHARED"); + assertThatThrownBy(() -> service.publish(mine, true, as(READER))).isInstanceOf(QPermissionDeniedException.class); + assertThat(service.publish(mine, true, as(WRITER)).get("visibility")).isEqualTo("SHARED"); - assertThat(service.list("shared", caller(READER))).hasSize(1); - assertThat(service.get(mine, caller(READER)).get("canEdit")).isEqualTo(false); - assertThatThrownBy(() -> service.update(mine, Map.of("title", "x"), caller(READER))).isInstanceOf(QPermissionDeniedException.class); - assertThatThrownBy(() -> service.delete(mine, caller(READER))).isInstanceOf(QPermissionDeniedException.class); + assertThat(service.list("shared", as(READER))).hasSize(1); + assertThat(service.get(mine, as(READER)).get("canEdit")).isEqualTo(false); + assertThatThrownBy(() -> service.update(mine, Map.of("title", "x"), as(READER))).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> service.delete(mine, as(READER))).isInstanceOf(QPermissionDeniedException.class); - Map copy = service.duplicate(mine, caller(READER)); + Map copy = service.duplicate(mine, as(READER)); assertThat(copy.get("title")).isEqualTo("Copy of Mine"); assertThat(copy.get("ownerUserId")).isEqualTo(READER); assertThat(copy.get("visibility")).isEqualTo("PERSONAL"); - assertThat(service.publish(mine, false, caller(WRITER)).get("visibility")).isEqualTo("PERSONAL"); - assertThat(service.list("shared", caller(READER))).isEmpty(); + assertThat(service.publish(mine, false, as(WRITER)).get("visibility")).isEqualTo("PERSONAL"); + assertThat(service.list("shared", as(READER))).isEmpty(); - service.delete(mine, caller(WRITER)); - assertThatThrownBy(() -> service.get(mine, caller(WRITER))).isInstanceOf(QNotFoundException.class); + service.delete(mine, as(WRITER)); + assertThatThrownBy(() -> service.get(mine, as(WRITER))).isInstanceOf(QNotFoundException.class); } @@ -188,22 +188,22 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest @Test void testValidation() throws Exception { - assertThatThrownBy(() -> service.create(body(" ", List.of()), caller(READER))).isInstanceOf(QBadRequestException.class); - assertThatThrownBy(() -> service.create(body("x", List.of(cell("a", "PYTHON", "x"))), caller(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.create(body(" ", List.of()), as(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.create(body("x", List.of(cell("a", "PYTHON", "x"))), as(READER))).isInstanceOf(QBadRequestException.class); Map notAList = body("x", null); notAList.put("cells", "nope"); - assertThatThrownBy(() -> service.create(notAList, caller(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.create(notAList, as(READER))).isInstanceOf(QBadRequestException.class); Map notObjects = body("x", null); notObjects.put("cells", List.of("nope")); - assertThatThrownBy(() -> service.create(notObjects, caller(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.create(notObjects, as(READER))).isInstanceOf(QBadRequestException.class); List> tooMany = new ArrayList<>(); for(int i = 0; i <= NotebookService.MAX_CELLS; i++) { tooMany.add(cell("k" + i, "SQL", "SELECT 1")); } - assertThatThrownBy(() -> service.create(body("x", tooMany), caller(READER))).isInstanceOf(QBadRequestException.class); - assertThatThrownBy(() -> service.get(null, caller(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.create(body("x", tooMany), as(READER))).isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> service.get(null, as(READER))).isInstanceOf(QBadRequestException.class); /////////////////////////////////////////////////////// // duplicate or missing keys are replaced by UUIDs // @@ -226,26 +226,26 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest @SuppressWarnings("unchecked") void testSnapshots() throws Exception { - Integer id = (Integer) service.create(body("Snap", List.of(cell("c1", "SQL", "SELECT * FROM person"))), caller(READER)).get("id"); + Integer id = (Integer) service.create(body("Snap", List.of(cell("c1", "SQL", "SELECT * FROM person"))), as(READER)).get("id"); SqlExecutionEngine engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance)); - Map response = engine.execute(ExecutionRequest.readOnly(BACKEND_NAME, "SELECT * FROM person", Map.of()), caller(READER)); + Map response = engine.execute(ExecutionRequest.readOnly(BACKEND_NAME, "SELECT * FROM person", Map.of()), as(READER)); String uuid = (String) response.get("executionUuid"); - assertThatThrownBy(() -> service.createSnapshot(id, Map.of("executionUuid", "missing"), caller(READER), null)).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.createSnapshot(id, Map.of("executionUuid", "missing"), as(READER), null)).isInstanceOf(QNotFoundException.class); - Map snapshot = service.createSnapshot(id, Map.of("executionUuid", uuid, "cellKey", "c1", "label", "people"), caller(READER), "SELECT * FROM person"); + Map snapshot = service.createSnapshot(id, Map.of("executionUuid", uuid, "cellKey", "c1", "label", "people"), as(READER), "SELECT * FROM person"); assertThat(snapshot.get("rowCount")).isEqualTo(2); assertThat(snapshot.get("truncated")).isEqualTo(true); assertThat(((List) ((Map) snapshot.get("result")).get("rows"))).hasSize(2); - List> snapshots = service.snapshots(id, caller(READER)); + List> snapshots = service.snapshots(id, as(READER)); assertThat(snapshots).hasSize(1); Integer snapshotId = (Integer) snapshot.get("id"); - assertThatThrownBy(() -> service.deleteSnapshot(id, 999, caller(READER))).isInstanceOf(QNotFoundException.class); - service.deleteSnapshot(id, snapshotId, caller(READER)); - assertThat(service.snapshots(id, caller(READER))).isEmpty(); + assertThatThrownBy(() -> service.deleteSnapshot(id, 999, as(READER))).isInstanceOf(QNotFoundException.class); + service.deleteSnapshot(id, snapshotId, as(READER)); + assertThat(service.snapshots(id, as(READER))).isEmpty(); } } diff --git a/src/test/resources/sql-workbench-h2.sql b/src/test/resources/sql-workbench-h2.sql index 7c94eea..f448d9a 100644 --- a/src/test/resources/sql-workbench-h2.sql +++ b/src/test/resources/sql-workbench-h2.sql @@ -1,4 +1,5 @@ -- H2 (MODE=MySQL) version of docs/ddl/postgres.sql, for unit tests. +DROP TABLE IF EXISTS sql_workbench_notebook_share; DROP TABLE IF EXISTS sql_workbench_result_snapshot; DROP TABLE IF EXISTS sql_workbench_notebook_version; DROP TABLE IF EXISTS sql_workbench_cell; @@ -70,6 +71,21 @@ 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) ); +CREATE TABLE IF NOT EXISTS sql_workbench_notebook_share ( + id INTEGER AUTO_INCREMENT 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, + modify_date TIMESTAMP, + 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) +); + -- 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 ( -- 2.54.0 From f823c9a50b749d85e4f5684733f969e4aa153ef8 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 06:17:10 -0500 Subject: [PATCH 3/5] fix(#3): harden versioning, cascades and visibility sync; more security tests From review of the standard-tables change: - Versioning: the next number passes both currentVersion and the newest stored version, the currentVersion update is checked, and failures are logged (QQQ turns post-customizer exceptions into record warnings). - Notebook delete cascade: a failed child delete aborts the delete. - Share visibility sync checks its update. - An admin ownership transfer resets ownerName to the new owner id. - Shares are audited at record level, like the other tables. - Tests: mixed insert/update batches, re-targeting rows into one's own notebook, planting ownership, a shared reader vs pinned results, and per-notebook children after denied deletes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 4 +- .../customizers/NotebookTableCustomizer.java | 23 ++++- .../customizers/ShareTableCustomizer.java | 10 ++- .../customizers/WorkbenchTableCustomizer.java | 18 +++- .../SqlWorkbenchTableMetaDataHelper.java | 2 +- .../notebook/NotebookVersioning.java | 19 ++++- .../metadata/TableSecurityMatrixTest.java | 84 +++++++++++++++++++ 8 files changed, 150 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90420fe..3c72411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The workbench's data becomes standard QQQ tables, secured by QQQ's own mechanism - 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 and pinned results; field-level for shares. +- 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. diff --git a/README.md b/README.md index deac56b..78c7d00 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,11 @@ Everything the notebook UI does can be done and inspected through the tables und - **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); `ALL` publishes the notebook to the shared library. The notebook's `visibility` follows its shares. +- **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. -Shares get field-level QQQ audits. Notebooks, cells, and pinned results get record-level audits, so their SQL text and result data are not copied into the audit tables. +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 diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java index d82da62..512c4f9 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/NotebookTableCustomizer.java @@ -37,12 +37,14 @@ import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -162,7 +164,15 @@ public class NotebookTableCustomizer extends WorkbenchTableCustomizer badInput(record, "Visibility follows the notebook's shares: share it with Everyone to publish it to the shared library."); continue; } - if(!changes(record, old, "ownerUserId")) + if(changes(record, old, "ownerUserId")) + { + //////////////////////////////////////////////////////////////// + // an admin transfer (the lock allows nobody else): the name // + // follows the new owner id, never a client-supplied value // + //////////////////////////////////////////////////////////////// + record.setValue("ownerName", record.getValueString("ownerUserId")); + } + else { record.removeValue("ownerName"); } @@ -203,7 +213,10 @@ public class NotebookTableCustomizer extends WorkbenchTableCustomizer /******************************************************************************* ** Delete the children of the notebooks QQQ authorized for deletion (the - ** foreign keys need them gone first). + ** foreign keys need them gone first). Inside the delete's transaction + ** when it has one (the notebook UI always does); a table-UI / API delete + ** has none, so there the children go first, in autocommit. A failed child + ** delete aborts the notebook delete. *******************************************************************************/ @Override public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException @@ -231,10 +244,14 @@ public class NotebookTableCustomizer extends WorkbenchTableCustomizer { for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME)) { - new DeleteAction().execute(new DeleteInput(config.applyPrefix(child)) + DeleteOutput output = new DeleteAction().execute(new DeleteInput(config.applyPrefix(child)) .withFlag(SqlWorkbenchActionFlag.INTERNAL) .withTransaction(deleteInput.getTransaction()) .withQueryFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.IN, ids)))); + if(CollectionUtils.nullSafeHasContents(output.getRecordsWithErrors())) + { + throw (new QException("Could not delete the notebook's " + child + " rows: " + output.getRecordsWithErrors().get(0).getErrors().get(0).getMessage())); + } } }); return (records); diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java index 6c288d9..67313ae 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/ShareTableCustomizer.java @@ -44,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -274,10 +275,15 @@ public class ShareTableCustomizer extends WorkbenchTableCustomizer .withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)) .withCriteria(new QFilterCriteria("audienceKey", QCriteriaOperator.EQUALS, SqlWorkbenchSecurity.AUDIENCE_ALL)))).getCount(); String visibility = allShares != null && allShares > 0 ? NotebookService.VISIBILITY_SHARED : NotebookService.VISIBILITY_PERSONAL; - new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) + QRecord updated = new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) .withFlag(SqlWorkbenchActionFlag.INTERNAL) .withTransaction(transaction) - .withRecord(new QRecord().withValue("id", notebookId).withValue("visibility", visibility).withValue("modifyDate", now))); + .withRecord(new QRecord().withValue("id", notebookId).withValue("visibility", visibility).withValue("modifyDate", now))).getRecords().get(0); + if(!ok(updated)) + { + LOG.warn("Could not sync a notebook's visibility with its shares", logPair("notebookId", notebookId), logPair("error", updated.getErrors().get(0).getMessage())); + throw (new QException("Could not update the notebook's visibility: " + updated.getErrors().get(0).getMessage())); + } } }); } diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java index 32675de..d1bc190 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/customizers/WorkbenchTableCustomizer.java @@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; @@ -39,6 +40,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -52,6 +54,8 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils; *******************************************************************************/ public abstract class WorkbenchTableCustomizer implements TableCustomizerInterface { + protected static final QLogger LOG = QLogger.getLogger(WorkbenchTableCustomizer.class); + /******************************************************************************* ** Stamp defaults before QQQ validates (required fields, record locks). @@ -249,7 +253,19 @@ public abstract class WorkbenchTableCustomizer implements TableCustomizerInterfa { for(Integer notebookId : notebookIds) { - NotebookVersioning.appendVersion(config(), clock(), notebookId, comment, transaction); + try + { + NotebookVersioning.appendVersion(config(), clock(), notebookId, comment, transaction); + } + catch(QException e) + { + //////////////////////////////////////////////////////////////////// + // QQQ turns post-customizer exceptions into record warnings, so // + // log here too: a change without its version must be visible // + //////////////////////////////////////////////////////////////////// + LOG.warn("Could not append a notebook version", e, logPair("notebookId", notebookId), logPair("comment", comment)); + throw (e); + } } } diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java index d92d0cd..38a96ed 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchTableMetaDataHelper.java @@ -203,7 +203,7 @@ public class SqlWorkbenchTableMetaDataHelper .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) .withLock(ownerLock(config, config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME) + ".ownerUserId", config.applyPrefix(JOIN_NOTEBOOK_SHARE))) .withLock(shareLock(config, "audienceKey"))) - .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD)) .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "audienceType", "audienceId"))) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("audienceKey", "createdByName", "createdByUserId", "createDate", "modifyDate"))); table.getField("audienceType").setPossibleValueSourceName(config.applyPrefix(ShareAudienceType.NAME)); diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java index 8043953..e705964 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/notebook/NotebookVersioning.java @@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; @@ -96,12 +97,26 @@ public final class NotebookVersioning SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook(found.get(0)); Instant now = clock.instant(); - int version = (notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion()) + 1; + /////////////////////////////////////////////////////////////////////// + // next number: past both currentVersion and the newest stored // + // version, so a stale currentVersion can never wedge later saves // + // on the (notebookId, versionNumber) unique key // + /////////////////////////////////////////////////////////////////////// + int latest = notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion(); + List newest = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME)) + .withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)).withOrderBy(new QFilterOrderBy("versionNumber", false)).withLimit(1)) + .withTransaction(transaction)).getRecords(); + if(!newest.isEmpty() && newest.get(0).getValueInteger("versionNumber") != null) + { + latest = Math.max(latest, newest.get(0).getValueInteger("versionNumber")); + } + int version = latest + 1; - new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) + UpdateOutput updated = new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)) .withFlag(SqlWorkbenchActionFlag.INTERNAL) .withTransaction(transaction) .withRecord(new QRecord().withValue("id", notebookId).withValue("currentVersion", version).withValue("modifyDate", now))); + NotebookService.throwIfErrors(updated.getRecords().get(0)); SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion() .withNotebookId(notebookId) diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java index 0c95692..818be73 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/metadata/TableSecurityMatrixTest.java @@ -486,6 +486,90 @@ class TableSecurityMatrixTest extends BaseSqlWorkbenchTest + /******************************************************************************* + ** Mixed batches and re-targeting: only the allowed rows change, and the + ** qbit's own bookkeeping (versions, visibility, cascades) never touches + ** a notebook the caller may not change. + *******************************************************************************/ + @Test + void testMixedBatchesRetargetingAndPlanting() throws Exception + { + Integer privateId = ids.get("Private"); + Integer library = ids.get("Library"); + Integer othersOwn = ids.get("Other's own"); + + /////////////////////////////////////////////////////////////// + // one insert batch: a cell in my notebook and one in OTHER's // + /////////////////////////////////////////////////////////////// + login(OWNER); + List inserted = new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME).withRecords(List.of( + new QRecord().withValue("notebookId", privateId).withValue("content", "SELECT 'mine'"), + new QRecord().withValue("notebookId", othersOwn).withValue("content", "SELECT 'theirs'")))).getRecords(); + assertThat(inserted.get(0).getErrors()).isNullOrEmpty(); + assertThat(inserted.get(1).getErrors()).isNotEmpty(); + assertThat(currentVersion(privateId)).isEqualTo(2); + assertThat(currentVersion(othersOwn)).isEqualTo(1); + assertThat(SystemUser.call(() -> QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, othersOwn))))).hasSize(1); + + ///////////////////////////////////////////////////////////////////// + // one update batch: OTHER's own cell and a (readable) library cell // + ///////////////////////////////////////////////////////////////////// + login(OTHER); + List updated = new UpdateAction().execute(new UpdateInput(SqlWorkbenchCell.TABLE_NAME).withRecords(List.of( + new QRecord().withValue("id", cellOf(othersOwn)).withValue("content", "SELECT 'edited'"), + new QRecord().withValue("id", cellOf(library)).withValue("content", "SELECT 'hijacked'")))).getRecords(); + assertThat(updated.get(0).getErrors()).isNullOrEmpty(); + assertThat(updated.get(1).getErrors()).isNotEmpty(); + assertThat(currentVersion(othersOwn)).isEqualTo(2); + assertThat(currentVersion(library)).isEqualTo(1); + + ////////////////////////////////////////////////////////////////////// + // re-targeting someone else's rows into my notebook is refused by // + // the stored-row lock (the new value alone would pass) // + ////////////////////////////////////////////////////////////////////// + assertThatThrownBy(() -> v1Update(SqlWorkbenchCell.TABLE_NAME, cellOf(library), Map.of("notebookId", othersOwn))).isInstanceOf(QException.class); + login(OTHER_SHARER); + Integer sharersOwn = notebook(OTHER_SHARER, "Sharer's own"); + login(OTHER_SHARER); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebookShare.TABLE_NAME, shareOf(library), Map.of("notebookId", sharersOwn))).isInstanceOf(QException.class); + assertThat(SystemUser.call(() -> new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebookShare.TABLE_NAME).withPrimaryKey(shareOf(library)))).getValueInteger("notebookId")).isEqualTo(library); + + //////////////////////////////////////////////////////////////////////// + // planting: an owner cannot hand a notebook to someone else // + //////////////////////////////////////////////////////////////////////// + login(OWNER); + assertThatThrownBy(() -> v1Update(SqlWorkbenchNotebook.TABLE_NAME, privateId, Map.of("ownerUserId", OTHER))).isInstanceOf(QException.class); + assertThat(SystemUser.call(() -> new GetAction().executeForRecord(new GetInput(SqlWorkbenchNotebook.TABLE_NAME).withPrimaryKey(privateId))).getValueString("ownerUserId")).isEqualTo(OWNER); + + /////////////////////////////////////////////////////////////////////// + // a reader the notebook is shared with sees its pinned result, but // + // can neither relabel nor delete it // + /////////////////////////////////////////////////////////////////////// + insertShare(privateId, "USER", SHARED_USER); + login(SHARED_USER); + Integer snapshotId = v1Query(SqlWorkbenchResultSnapshot.TABLE_NAME).stream().filter(r -> privateId.equals(r.getValueInteger("notebookId"))).findFirst().orElseThrow().getValueInteger("id"); + assertThatThrownBy(() -> v1Update(SqlWorkbenchResultSnapshot.TABLE_NAME, snapshotId, Map.of("label", "mine now"))).isInstanceOf(QException.class); + assertThat(v1Delete(SqlWorkbenchResultSnapshot.TABLE_NAME, snapshotId)).isZero(); + + /////////////////////////////////////////////////////////////////////// + // denied notebook deletes never cascade: children all still there // + /////////////////////////////////////////////////////////////////////// + login(OTHER); + assertThat(v1Delete(SqlWorkbenchNotebook.TABLE_NAME, library)).isZero(); + login(SHARED_USER); + assertThat(v1Delete(SqlWorkbenchNotebook.TABLE_NAME, privateId)).isZero(); + for(Integer id : List.of(library, privateId)) + { + for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME)) + { + assertThat(SystemUser.call(() -> QueryAction.execute(child, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, id))))).as(child + " of " + id).isNotEmpty(); + } + } + assertThat(SystemUser.call(() -> QueryAction.execute(SqlWorkbenchResultSnapshot.TABLE_NAME, new QQueryFilter()))).hasSize(1); + } + + + /******************************************************************************* ** Editing a share re-targets it (new audience key, visibility in step); ** audiences cannot edit or remove the share that names them. -- 2.54.0 From 663c209f4e924a9676dbb6cb95cf4d786389d086 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 06:21:13 -0500 Subject: [PATCH 4/5] fix(#3): gate record actions with a context-free permission checker QQQ's UseTablePermissionCustomPermissionChecker needs a QContext instance when available permissions are listed and threw a NullPointerException there (seen in Website-Backend's permission-listing tests). The record actions now use SqlWorkbenchPermissionChecker, which requires one named session permission (share-table insert, notebook insert / edit). Test: listing available permissions with no QContext. Co-Authored-By: Claude Opus 5.5 --- .../SqlWorkbenchPermissionChecker.java | 111 ++++++++++++++++++ ...orkbenchRecordActionsMetaDataProducer.java | 4 +- .../SqlWorkbenchQBitProducerTest.java | 11 ++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchPermissionChecker.java 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/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java index 023b8a8..4b81501 100644 --- a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchRecordActionsMetaDataProducer.java @@ -19,6 +19,7 @@ package com.kingsrook.qbits.sqlworkbench.metadata; import java.util.ArrayList; import java.util.List; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchPermissionChecker; import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook; @@ -26,7 +27,6 @@ import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare; import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion; import com.kingsrook.qbits.sqlworkbench.processes.SqlWorkbenchRecordActionSteps; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; -import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; @@ -121,7 +121,7 @@ public class SqlWorkbenchRecordActionsMetaDataProducer extends QBitComponentMeta .withMaxInputRecords(1) .withPermissionRules(new QPermissionRules() .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) - .withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(permissionTable, permission))) + .withCustomPermissionChecker(SqlWorkbenchPermissionChecker.requiring(permissionTable + "." + permission.getPermissionSuffix()))) .withStepList(steps)); } diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java index 016f8b5..bc0f3d3 100644 --- a/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/SqlWorkbenchQBitProducerTest.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; @@ -92,6 +93,16 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest assertThat(qInstance.getSecurityKeyType("sqlWorkbenchUser").getAllAccessKeyName()).isEqualTo("sqlWorkbenchAllAccess"); assertThat(qInstance.getSecurityKeyType("sqlWorkbenchAudience")).isNotNull(); + //////////////////////////////////////////////////////////////////////// + // role-management UIs list available permissions without a QContext // + // instance; the record actions must not break that // + //////////////////////////////////////////////////////////////////////// + QContext.clear(); + assertThat(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance)) + .contains("sqlWorkbenchNotebook.read", "sqlWorkbenchNotebookShare.insert", "sqlWorkbench.hasAccess") + .doesNotContain("sqlWorkbenchExecution.delete", "sqlWorkbenchNotebookVersion.insert"); + loginAs(READER); + QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(SqlWorkbenchWidgetMetaDataProducer.NAME); assertThat(widget.getType()).isEqualTo("SQL_WORKBENCH"); -- 2.54.0 From 8346a5fe273d2ddf1bce77fec746aad895c72d0b Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 12:24:46 -0500 Subject: [PATCH 5/5] build: build against the released QQQ 4.1.0-RC.4 RC.4 lets partial edits of child rows pass joined record locks (qqq#1073), which the table security matrix tests need. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 311cad0..1315520 100644 --- a/pom.xml +++ b/pom.xml @@ -251,7 +251,7 @@ - + qqq-snapshot @@ -259,7 +259,7 @@ true - 4.1.0-RC.3 + 4.1.0-RC.4 -- 2.54.0