From dae957153ec43c0d4602bfccb81e7c967345daa3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 26 Sep 2026 09:24:11 -0500 Subject: [PATCH 1/4] feat(qbits): wire demo qbits into core and full profiles (#6) --- qqq-all-app/README.md | 7 +- qqq-all-app/pom.xml | 7 + .../com/kingsrook/qqq/all/CoreRuntime.java | 1 + .../com/kingsrook/qqq/all/DemoQBitData.java | 113 +++++++++++ .../java/com/kingsrook/qqq/all/DemoQBits.java | 183 ++++++++++++++++++ .../qqq/all/FullProfileApplication.java | 6 +- .../qqq/all/FullProfileSettings.java | 3 +- .../kingsrook/qqq/all/QqqAllApplication.java | 11 ++ .../qqq/all/CoreApplicationTest.java | 25 ++- .../com/kingsrook/qqq/all/CoreQBitsTest.java | 44 +++++ .../kingsrook/qqq/all/FullProfileTest.java | 4 +- .../com/kingsrook/qqq/all/FullQBitsTest.java | 49 +++++ 12 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java create mode 100644 qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java create mode 100644 qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java create mode 100644 qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md index e5b5583..9036a5f 100644 --- a/qqq-all-app/README.md +++ b/qqq-all-app/README.md @@ -9,7 +9,9 @@ java -jar qqq-all-app/target/qqq-all-app-4.1.0-SNAPSHOT.jar The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`. -Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations; role-based permissions are wired in task C5. +Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API. + +The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. The `orderStored` webhook event type is registered for orders, ready for a subscription to a receiver you control. Core does not configure an outbound webhook destination. ## Full profile @@ -27,5 +29,6 @@ Configure the following environment variables before starting the same jar: | ESB | `QQQ_ALL_ARTEMIS_URL`, `QQQ_ALL_RABBITMQ_URL`, `QQQ_ALL_RABBITMQ_USER`, `QQQ_ALL_RABBITMQ_PASSWORD` | — | | Mailpit SMTP | `QQQ_ALL_SMTP_HOST` | `QQQ_ALL_SMTP_PORT` (1025) | | Keycloak OIDC | `QQQ_ALL_OIDC_BASE_URL`, `QQQ_ALL_OIDC_CLIENT_ID`, `QQQ_ALL_OIDC_CLIENT_SECRET` | `QQQ_ALL_OIDC_EXTERNAL_BASE_URL` (base URL), `QQQ_ALL_OIDC_SCOPES` (`openid email profile`) | +| OpenSearch quick search | `QQQ_ALL_OPENSEARCH_HOST` | `QQQ_ALL_OPENSEARCH_PORT` (9200), `QQQ_ALL_OPENSEARCH_INDEX` (`qqq-all-customers`), `QQQ_ALL_OPENSEARCH_USER`, `QQQ_ALL_OPENSEARCH_PASSWORD`, `QQQ_ALL_OPENSEARCH_SSL` (false) | -The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this C4 profile expects those resources to exist. +The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Quick search is configured for customer `name` and `email`; run `quickSearchFullReindex` after OpenSearch and the customer data are ready. The SFTP integration adds its import source and staging tables plus `SFTPImportFileSyncProcess`; `externalImportFile` remains the direct SFTP file sample. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo records, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this profile expects those resources to exist. diff --git a/qqq-all-app/pom.xml b/qqq-all-app/pom.xml index a5c6648..056cd78 100644 --- a/qqq-all-app/pom.xml +++ b/qqq-all-app/pom.xml @@ -39,6 +39,13 @@ com.kingsrook.qqqqqq-esb com.kingsrook.qqqqqq-frontend-next com.kingsrook.qbitsqbit-user-role-permissions + com.kingsrook.qbitsqbit-quick-search + com.kingsrook.qbitsqbit-customizable-table-views + com.kingsrook.qbitsqbit-standard-process-trace + com.kingsrook.qbitsqbit-webhooks + com.kingsrook.qbitsqbit-workflows + com.kingsrook.qbitsqbit-geo-data + com.kingsrook.qbitsqbit-sftp-data-integration org.postgresqlpostgresql com.mysqlmysql-connector-j com.h2databaseh2 diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java index 1492c66..56de81d 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java @@ -36,6 +36,7 @@ final class CoreRuntime implements AutoCloseable Integer brokerPort = findFreePort(); Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath(); String brokerUrl = "tcp://127.0.0.1:" + brokerPort; + DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl).defineQInstance()); Configuration configuration = new ConfigurationImpl() .setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false) .setBindingsDirectory(brokerDirectory.resolve("bindings").toString()) diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java new file mode 100644 index 0000000..cf22dcf --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java @@ -0,0 +1,113 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; + +/** Creates repeatable local tables for the qbit metadata exposed by core. */ +final class DemoQBitData +{ + private DemoQBitData() + { + } + + static void ensureSchema(Path dataDirectory, QInstance instance) throws SQLException + { + try(Connection connection = DriverManager.getConnection( + "jdbc:h2:file:" + CoreData.h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + Statement statement = connection.createStatement()) + { + for(QTableMetaData table : instance.getTables().values()) + { + if(!"h2".equals(table.getBackendName()) || "customer".equals(table.getName()) || "order".equals(table.getName())) + { + continue; + } + String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details + ? details.getTableName() : table.getName(); + List columns = new ArrayList<>(); + for(QFieldMetaData field : table.getFields().values()) + { + String name = field.getBackendName() == null ? field.getName() : field.getBackendName(); + String type = sqlType(field.getType()); + String identity = field.getName().equals(table.getPrimaryKeyField()) + && (field.getType() == QFieldType.INTEGER || field.getType() == QFieldType.LONG) + ? " GENERATED BY DEFAULT AS IDENTITY" : ""; + String primaryKey = field.getName().equals(table.getPrimaryKeyField()) ? " PRIMARY KEY" : ""; + columns.add(identifier(name) + " " + type + identity + primaryKey); + } + if(!columns.isEmpty()) + { + statement.execute("CREATE TABLE IF NOT EXISTS " + identifier(tableName) + + " (" + String.join(", ", columns) + ")"); + } + } + statement.execute("MERGE INTO shipping_country (id, name, alpha2_code) KEY (id) VALUES (1, 'United States', 'US')"); + statement.execute("MERGE INTO shipping_stateProvince (id, name, country_id) KEY (id) VALUES (1, 'Illinois', 1)"); + statement.execute("MERGE INTO shipping_city (id, name, state_province_id, latitude, longitude) KEY (id) " + + "VALUES (1, 'Chicago', 1, 41.8781, -87.6298)"); + statement.execute("MERGE INTO address (id, customer_id, line1, city_id) KEY (id) " + + "VALUES (1, 1, '1 Demo Street', 1)"); + statement.execute("MERGE INTO customizable_table (id, table_name, is_active, default_table_view_id) KEY (id) " + + "VALUES (1, 'customer', true, 1)"); + statement.execute("MERGE INTO table_view (id, customizable_table_id, name) KEY (id) " + + "VALUES (1, 1, 'Customer Directory')"); + statement.execute("MERGE INTO table_view_field (id, table_view_id, field_name, access_level) KEY (id) VALUES " + + "(1, 1, 'customer.id', 'READ_ONLY'), " + + "(2, 1, 'customer.name', 'EDITABLE_OPTIONAL'), " + + "(3, 1, 'customer.email', 'EDITABLE_OPTIONAL')"); + statement.execute("MERGE INTO workflow (id, name, workflow_type_name, table_name, current_workflow_revision_id) " + + "KEY (id) VALUES (1, 'Order Status Review', 'RecordWorkflow', 'order', 1)"); + statement.execute("MERGE INTO workflow_revision (id, workflow_id, version_no, start_step_no, commit_message, author) " + + "KEY (id) VALUES (1, 1, 1, 1, 'Demo order review', 'qqq-all')"); + statement.execute("MERGE INTO workflow_step (id, workflow_revision_id, step_no, workflow_step_type_name, summary, input_values_json) " + + "KEY (id) VALUES (1, 1, 1, 'updateInputRecordField', 'Mark order reviewed', " + + "'{\"fieldName\":\"status\",\"value\":\"REVIEWED\"}')"); + } + } + + private static String identifier(String value) + { + if(value == null || !value.matches("[A-Za-z][A-Za-z0-9_]*")) + { + throw new IllegalArgumentException("Unsupported demo schema identifier"); + } + if("value".equalsIgnoreCase(value) || "key".equalsIgnoreCase(value)) + { + return "`" + value + "`"; + } + return value; + } + + private static String sqlType(QFieldType type) + { + return switch(type) + { + case INTEGER -> "INTEGER"; + case LONG -> "BIGINT"; + case DECIMAL -> "DECIMAL(20,6)"; + case BOOLEAN -> "BOOLEAN"; + case DATE -> "DATE"; + case TIME -> "TIME"; + case DATE_TIME -> "TIMESTAMP"; + case BLOB -> "BLOB"; + case STRING, PASSWORD -> "VARCHAR(4000)"; + case TEXT, HTML -> "CLOB"; + }; + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java new file mode 100644 index 0000000..96edab5 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java @@ -0,0 +1,183 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.util.List; +import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitConfig; +import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitProducer; +import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig; +import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer; +import com.kingsrook.qbits.quicksearch.SearchableFieldConfig; +import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitConfig; +import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitProducer; +import com.kingsrook.qbits.geodata.GeoDataQBitConfig; +import com.kingsrook.qbits.geodata.GeoDataQBitProducer; +import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitConfig; +import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitProducer; +import com.kingsrook.qbits.standardprocesstrace.utils.StandardProcessTracer; +import com.kingsrook.qbits.webhooks.WebhooksQBitConfig; +import com.kingsrook.qbits.webhooks.WebhooksQBitProducer; +import com.kingsrook.qbits.webhooks.model.WebhookEventCategory; +import com.kingsrook.qbits.webhooks.registry.WebhookEventType; +import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry; +import com.kingsrook.qbits.workflows.WorkflowsQBitConfig; +import com.kingsrook.qbits.workflows.WorkflowsQBitProducer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; +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.qbits.ProvidedOrSuppliedTableConfig; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.processes.QQQProcessesMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.tables.QQQTablesMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; + +/** Registers the locally usable qbits against the demo's relational backend. */ +final class DemoQBits +{ + private DemoQBits() + { + } + + static void addCore(QInstance instance, String backendName) throws QException + { + new QQQTablesMetaDataProvider().defineAll(instance, backendName, backendName, null); + new QQQProcessesMetaDataProvider().defineAll(instance, backendName, backendName, null); + instance.addScheduler(new SimpleSchedulerMetaData().withName("demoScheduler")); + new ScheduledJobsMetaDataProvider().defineAll(instance, backendName, null); + instance.addPossibleValueSource(new TimeZonePossibleValueSourceMetaDataProvider().produce()); + instance.addPossibleValueSource(TablesPossibleValueSourceMetaDataProvider.defineTablesPossibleValueSource(instance)); + instance.addPossibleValueSource(new QPossibleValueSource().withName("apiName") + .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING) + .withEnumValues(List.of(new QPossibleValue<>("none", "No API in this demo")))); + instance.addPossibleValueSource(new QPossibleValueSource().withName("apiVersion") + .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING) + .withEnumValues(List.of(new QPossibleValue<>("none", "No API version")))); + if(instance.getPossibleValueSource("role") == null) + { + instance.addPossibleValueSource(new QPossibleValueSource().withName("role") + .withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER) + .withEnumValues(List.of(new QPossibleValue<>(0, "No role")))); + } + + MetaDataCustomizerInterface tableCustomizer = + (i, table) -> + { + table.setBackendName(backendName); + table.setBackendDetails(new RDBMSTableBackendDetails() + .withTableName(QInstanceEnricher.inferBackendName(table.getName()))); + QInstanceEnricher.setInferredFieldBackendNames(table); + return table; + }; + + new CustomizableTableViewsQBitProducer() + .withQBitConfig(new CustomizableTableViewsQBitConfig() + .withTableMetaDataCustomizer(tableCustomizer)) + .produce(instance).addSelfToInstance(instance); + CustomizableTableViewsQBitProducer.activateTableMetaDataPersonalizerInterface(instance); + + StandardProcessTraceQBitConfig traceConfig = new StandardProcessTraceQBitConfig() + .withTableMetaDataCustomizer(tableCustomizer); + StandardProcessTracer.setStandardProcessTraceQBitConfig(traceConfig); + new StandardProcessTraceQBitProducer() + .withStandardProcessTraceQBitConfig(traceConfig) + .produce(instance).addSelfToInstance(instance); + instance.getProcess("syncOrder").setProcessTracerCodeReference(new QCodeReference(StandardProcessTracer.class)); + + new WebhooksQBitProducer() + .withQBitConfig(new WebhooksQBitConfig().withDefaultBackendNameForTables(backendName) + .withSchedulerName("demoScheduler") + .withTableMetaDataCustomizer(tableCustomizer)) + .produce(instance).addSelfToInstance(instance); + WebhooksRegistry.ofOrWithNew(instance).registerWebhookEventType(new WebhookEventType() + .withName("orderStored").withLabel("Order Stored") + .withCategory(WebhookEventCategory.STORE).withTableName("order")); + + new WorkflowsQBitProducer() + .withQBitConfig(new WorkflowsQBitConfig().withTableMetaDataCustomizer(tableCustomizer)) + .produce(instance).addSelfToInstance(instance); + + new GeoDataQBitProducer() + .withConfig(new GeoDataQBitConfig().withBackendName(backendName).withTableNamePrefix("shipping")) + .produce(instance, "shipping-geo"); + instance.addTable(new QTableMetaData().withName("shipping_country").withLabel("Country") + .withBackendName(backendName).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("alpha2Code", QFieldType.STRING).withBackendName("alpha2_code"))); + instance.addTable(new QTableMetaData().withName("shipping_stateProvince").withLabel("State Province") + .withBackendName(backendName).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("countryId", QFieldType.INTEGER).withBackendName("country_id") + .withPossibleValueSourceName("shipping_country"))); + instance.addTable(new QTableMetaData().withName("shipping_city").withLabel("City") + .withBackendName(backendName).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("stateProvinceId", QFieldType.INTEGER).withBackendName("state_province_id") + .withPossibleValueSourceName("shipping_stateProvince")) + .withField(new QFieldMetaData("latitude", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("longitude", QFieldType.DECIMAL))); + instance.addTable(new QTableMetaData().withName("address").withLabel("Address") + .withBackendName(backendName).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id")) + .withField(new QFieldMetaData("line1", QFieldType.STRING)) + .withField(new QFieldMetaData("cityId", QFieldType.INTEGER).withBackendName("city_id") + .withPossibleValueSourceName("shipping_city"))); + } + + static void addFull(QInstance instance, FullProfileSettings settings) throws QException + { + QuickSearchQBitConfig searchConfig = new QuickSearchQBitConfig() + .withBackendName("postgres") + .withOpensearchHost(settings.get("OPENSEARCH_HOST")) + .withOpensearchPort(settings.port("OPENSEARCH_PORT", "9200")) + .withOpensearchIndexName(settings.getOrDefault("OPENSEARCH_INDEX", "qqq-all-customers")) + .withOpensearchUsername(settings.get("OPENSEARCH_USER")) + .withOpensearchPassword(settings.get("OPENSEARCH_PASSWORD")) + .withUseSsl(Boolean.parseBoolean(settings.getOrDefault("OPENSEARCH_SSL", "false"))) + .withSearchableTable("customer", List.of(new SearchableFieldConfig("name"), + new SearchableFieldConfig("email"))); + new QuickSearchQBitProducer().withConfig(searchConfig).produce(instance); + instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits") + .withArtifactId("quick-search").withVersion("0.2.1-SNAPSHOT").withConfig(searchConfig)); + + new SavedBulkLoadProfileMetaDataProvider().defineAll(instance, "postgres", null); + new SavedReportsMetaDataProvider().defineAll(instance, "postgres", "sftp", null); + new SFTPDataIntegrationQBitProducer().withSftpDataIntegrationQBitConfig(new SFTPDataIntegrationQBitConfig() + .withSourceFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp")) + .withDestinationFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp")) + .withStagingFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("files")) + .withSchedulerName("demoScheduler") + .withTableMetaDataCustomizer((i, table) -> + { + if(table.getBackendName() == null) + { + table.setBackendName("postgres"); + table.setBackendDetails(new RDBMSTableBackendDetails() + .withTableName(QInstanceEnricher.inferBackendName(table.getName()))); + QInstanceEnricher.setInferredFieldBackendNames(table); + } + return table; + })).produce(instance, "demo-import"); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java index 3e81a93..7ac3ddf 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java @@ -58,11 +58,13 @@ final class FullProfileApplication extends AbstractQQQApplication @Override public QInstance defineQInstance() throws QException { - QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL")).defineQInstance(); + QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false).defineQInstance(); addBackends(instance); addTables(instance); addAuthentication(instance); addEsb(instance); + DemoQBits.addCore(instance, "postgres"); + DemoQBits.addFull(instance, settings); EmailMessagingProviderMetaData mail = new EmailMessagingProviderMetaData() .withSmtpServer(settings.get("SMTP_HOST")) @@ -122,7 +124,7 @@ final class FullProfileApplication extends AbstractQQQApplication .withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json")) .withField(new QFieldMetaData("id", QFieldType.INTEGER)) .withField(new QFieldMetaData("name", QFieldType.STRING))); - instance.addTable(new QTableMetaData().withName("importFile").withLabel("Import File") + instance.addTable(new QTableMetaData().withName("externalImportFile").withLabel("External Import File") .withBackendName("sftp").withPrimaryKeyField("id") .withBackendDetails(new SFTPTableBackendDetails().withBasePath("imports") .withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json")) diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java index 9842732..f89b94b 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileSettings.java @@ -20,7 +20,8 @@ final class FullProfileSettings "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "SFTP_HOST", "SFTP_USER", "SFTP_PASSWORD", "ARTEMIS_URL", "RABBITMQ_URL", "RABBITMQ_USER", "RABBITMQ_PASSWORD", - "SMTP_HOST", "OIDC_BASE_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"); + "SMTP_HOST", "OIDC_BASE_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", + "OPENSEARCH_HOST"); private final Map values; diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java index 9eec1bd..f143fa4 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java @@ -51,11 +51,18 @@ public final class QqqAllApplication extends AbstractQQQApplication { private final Path dataDirectory; private final String brokerUrl; + private final boolean includeDemoQbits; public QqqAllApplication(Path dataDirectory, String brokerUrl) + { + this(dataDirectory, brokerUrl, true); + } + + QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits) { this.dataDirectory = dataDirectory; this.brokerUrl = brokerUrl; + this.includeDemoQbits = includeDemoQbits; } public static void main(String[] args) throws Exception @@ -130,6 +137,10 @@ public final class QqqAllApplication extends AbstractQQQApplication .withEndpointPath("/health").withIndicators(List.of(new MemoryHealthIndicator().withThreshold(99)))); QJavalinMetaData.ofOrWithNew(instance) .withAdditionalRouteProviderReference(new QCodeReference(JavalinHealthRouteProvider.class)); + if(includeDemoQbits) + { + DemoQBits.addCore(instance, "h2"); + } return instance; } diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java index de521f7..3cee6fa 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java @@ -17,6 +17,9 @@ import java.util.List; import java.util.Map; 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.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -71,10 +74,14 @@ class CoreApplicationTest assertEquals("core:demo", mockAuth.createSession(runtime.getLauncher().getQInstance(), Map.of("sessionId", DemoUsers.DEMO_SESSION)).getUser().getIdReference()); - for(String table : List.of("customer", "order", "orderLine", "product")) + for(String table : List.of("customer", "order", "orderLine", "product", "address", "shipping_city")) { assertFalse(new QueryAction().execute(new QueryInput(table)).getRecords().isEmpty(), table); } + assertEquals("Customer Directory", new QueryAction().execute(new QueryInput("TableView")) + .getRecords().getFirst().getValueString("name")); + assertEquals("Order Status Review", new QueryAction().execute(new QueryInput("workflow")) + .getRecords().getFirst().getValueString("name")); waitFor(() -> QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents") != null && QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents").getState() == EsbTriggerState.RUNNING); @@ -82,6 +89,16 @@ class CoreApplicationTest new QRecord().withValue("orderNo", "ORD-1002") .withValue("customerId", 2).withValue("status", "NEW")))); waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0); + waitFor(() -> !new QueryAction().execute(new QueryInput("processTrace")).getRecords().isEmpty()); + + RunProcessInput workflow = new RunProcessInput(); + workflow.setProcessName("RunRecordWorkflow"); + workflow.setCallback(QProcessCallbackFactory.forPrimaryKey("id", 1)); + workflow.addValue("tableName", "order"); + workflow.addValue("workflowId", 1); + workflow.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + new RunProcessAction().execute(workflow); + assertFalse(new QueryAction().execute(new QueryInput("workflowRunLog")).getRecords().isEmpty()); } finally { @@ -90,13 +107,13 @@ class CoreApplicationTest } } - private static void waitFor(java.util.function.BooleanSupplier condition) throws Exception + private static void waitFor(java.util.concurrent.Callable condition) throws Exception { long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); - while(!condition.getAsBoolean() && System.nanoTime() < deadline) + while(!condition.call() && System.nanoTime() < deadline) { Thread.sleep(50); } - assertTrue(condition.getAsBoolean()); + assertTrue(condition.call()); } } diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java new file mode 100644 index 0000000..479bb27 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreQBitsTest.java @@ -0,0 +1,44 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.nio.file.Path; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry; +import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CoreQBitsTest +{ + @TempDir Path dataDirectory; + + @Test + void coreExposesLocalQbitsAndOrderTrace() throws Exception + { + QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance(); + new QInstanceValidator().validate(instance); + Set artifacts = instance.getQBits().values().stream() + .map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet()); + assertTrue(artifacts.containsAll(Set.of("customizable-table-views", "standard-process-trace", + "webhooks", "workflows", "qbit-geo-data"))); + assertNotNull(instance.getTable("TableView")); + assertNotNull(instance.getTable("processTrace")); + assertNotNull(instance.getTable("webhook")); + assertNotNull(instance.getTable("workflow")); + assertNotNull(instance.getProcess("syncOrder").getProcessTracerCodeReference()); + assertNotNull(WebhooksRegistry.of(instance).getWebhookEventType("orderStored")); + assertNotNull(WorkflowsRegistry.of(instance).getWorkflowType("RecordWorkflow")); + assertNotNull(instance.getTable("address").getField("cityId").getPossibleValueSourceName()); + assertTrue(artifacts.stream().noneMatch("quick-search"::equals)); + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java index 1519893..32fd00a 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullProfileTest.java @@ -52,7 +52,7 @@ class FullProfileTest assertEquals("http://minio.example.test:9000", ((MinioBackendMetaData) instance.getBackend("s3")).getEndpoint()); assertEquals("s3", instance.getTable("document").getBackendName()); - assertEquals("sftp", instance.getTable("importFile").getBackendName()); + assertEquals("sftp", instance.getTable("externalImportFile").getBackendName()); EsbInstanceMetaData esb = EsbInstanceMetaData.of(instance); assertEquals("tcp://artemis.example.test:61616", esb.getProvider("artemis").getUrl()); @@ -101,6 +101,8 @@ class FullProfileTest values.put("QQQ_ALL_OIDC_BASE_URL", "https://keycloak.example.test/realms/demo"); values.put("QQQ_ALL_OIDC_CLIENT_ID", "qqq-all"); values.put("QQQ_ALL_OIDC_CLIENT_SECRET", "fixture"); + values.put("QQQ_ALL_OPENSEARCH_HOST", "127.0.0.1"); + values.put("QQQ_ALL_OPENSEARCH_PORT", "1"); return values; } } diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java new file mode 100644 index 0000000..74f8809 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java @@ -0,0 +1,49 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.nio.file.Path; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FullQBitsTest +{ + @TempDir Path dataDirectory; + + @Test + void fullAddsCustomerSearchAndSftpImportToTheDemo() throws Exception + { + FullProfileSettings settings = FullProfileSettings.from(FullProfileTest.environment()); + QInstance instance = new FullProfileApplication(dataDirectory, settings).defineQInstance(); + new QInstanceValidator().validate(instance); + + Set artifacts = instance.getQBits().values().stream() + .map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet()); + assertTrue(artifacts.containsAll(Set.of("quick-search", "user-role-permissions", + "customizable-table-views", "standard-process-trace", "webhooks", "workflows", + "qbit-geo-data", "sftp-data-integration"))); + assertNotNull(instance.getApp("quickSearchAdmin")); + assertNotNull(instance.getProcess("quickSearchFullReindex")); + assertNotNull(instance.getProcess("quickSearchReconcileIndex")); + assertNotNull(instance.getProcess("SFTPImportFileSyncProcess")); + assertNotNull(instance.getTable("SFTPImportSourceFileTable")); + assertEquals("sftp", instance.getTable("SFTPImportSourceFileTable").getBackendName()); + QuickSearchQBitConfig search = (QuickSearchQBitConfig) instance.getQBits().values().stream() + .filter(qbit -> "quick-search".equals(qbit.getArtifactId())).findFirst().orElseThrow().getConfig(); + assertEquals("customer", search.getSearchableTables().getFirst().getTableName()); + assertEquals(Set.of("name", "email"), search.getSearchableTables().getFirst().getFields().stream() + .map(field -> field.getFieldName()).collect(Collectors.toSet())); + } +} -- 2.54.0 From 3e6030fe3556888206296ee8c0a140f746cd3fda Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 26 Sep 2026 09:41:53 -0500 Subject: [PATCH 2/4] fix(qbits): enforce demo permissions and deliver order webhook (#6) --- qqq-all-app/README.md | 4 +- .../com/kingsrook/qqq/all/CoreRuntime.java | 15 ++++- .../qqq/all/DemoOrderWebhookContent.java | 28 ++++++++++ .../qqq/all/DemoOrderWebhookReceiver.java | 55 +++++++++++++++++++ .../com/kingsrook/qqq/all/DemoQBitData.java | 7 ++- .../java/com/kingsrook/qqq/all/DemoQBits.java | 3 +- .../java/com/kingsrook/qqq/all/DemoUsers.java | 21 +++++++ .../kingsrook/qqq/all/QqqAllApplication.java | 3 + .../qqq/all/CoreApplicationTest.java | 36 ++++++++++++ 9 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java create mode 100644 qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md index 9036a5f..8bc016e 100644 --- a/qqq-all-app/README.md +++ b/qqq-all-app/README.md @@ -9,9 +9,9 @@ java -jar qqq-all-app/target/qqq-all-app-4.1.0-SNAPSHOT.jar The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`. -Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API. +Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. Admin can read and write the customer, order, order-line, product, and address tables; Demo User can read them but cannot write them. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API. -The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. The `orderStored` webhook event type is registered for orders, ready for a subscription to a receiver you control. Core does not configure an outbound webhook destination. +The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its loopback receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent`, and inspect `/demo/order-webhook-receipts` to see the delivered payload. The receiver is available only in the loopback-bound core profile. ## Full profile diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java index 56de81d..267204f 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java @@ -9,6 +9,9 @@ package com.kingsrook.qqq.all; import java.io.IOException; import java.net.ServerSocket; import java.nio.file.Path; +import com.kingsrook.qbits.webhooks.actions.WebhookSubscriptionsHelper; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; import org.apache.activemq.artemis.core.config.Configuration; @@ -36,7 +39,8 @@ final class CoreRuntime implements AutoCloseable Integer brokerPort = findFreePort(); Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath(); String brokerUrl = "tcp://127.0.0.1:" + brokerPort; - DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl).defineQInstance()); + DemoOrderWebhookReceiver.clear(); + DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl).defineQInstance(), port); Configuration configuration = new ConfigurationImpl() .setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false) .setBindingsDirectory(brokerDirectory.resolve("bindings").toString()) @@ -53,6 +57,15 @@ final class CoreRuntime implements AutoCloseable new QApplicationLauncherConfig().withRegisterShutdownHook(false) .withServerCustomizer(server -> server.withPort(port) .withJavalinConfigCustomizer(config -> config.jetty.host = "127.0.0.1"))); + QContext.init(launcher.getQInstance(), new QSystemUserSession()); + try + { + WebhookSubscriptionsHelper.clearMemoizations(); + } + finally + { + QContext.clear(); + } return new CoreRuntime(broker, launcher); } catch(Exception | LinkageError e) diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java new file mode 100644 index 0000000..77c54a7 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookContent.java @@ -0,0 +1,28 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.webhooks.actions.WebhookEventTypeCustomizerInterface; +import com.kingsrook.qbits.webhooks.model.WebhookEventContent; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + +/** Builds a self-contained demo payload without requiring a separate public API definition. */ +public class DemoOrderWebhookContent implements WebhookEventTypeCustomizerInterface +{ + @Override + public WebhookEventContent buildEventContent(QRecord record, String eventType, String apiName, String apiVersion) + { + Map body = new LinkedHashMap<>(); + body.put("eventType", eventType); + body.put("orderNo", record.getValueString("orderNo")); + body.put("orderId", record.getValueInteger("id")); + return new WebhookEventContent().withPostBody(JsonUtils.toJson(body)); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java new file mode 100644 index 0000000..b51186a --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java @@ -0,0 +1,55 @@ +/* + * 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 https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.kingsrook.qqq.all; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface; +import io.javalin.config.JavalinConfig; +import static io.javalin.apibuilder.ApiBuilder.get; +import static io.javalin.apibuilder.ApiBuilder.post; + +/** Local receiver that makes successful order webhook delivery visible in the core demo. */ +public class DemoOrderWebhookReceiver implements QJavalinRouteProviderInterface +{ + private static final List RECEIPTS = new CopyOnWriteArrayList<>(); + + static void clear() + { + RECEIPTS.clear(); + } + + @Override + public void setQInstance(QInstance instance) + { + } + + @Override + public void acceptJavalinConfig(JavalinConfig config) + { + config.routes.apiBuilder(() -> + { + post("/demo/order-webhook", context -> + { + String body = context.body(); + if(body.length() > 8192) + { + context.status(413); + return; + } + RECEIPTS.add(body); + if(RECEIPTS.size() > 20) + { + RECEIPTS.removeFirst(); + } + context.status(204); + }); + get("/demo/order-webhook-receipts", context -> context.json(List.copyOf(RECEIPTS))); + }); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java index cf22dcf..db52bc9 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBitData.java @@ -26,7 +26,7 @@ final class DemoQBitData { } - static void ensureSchema(Path dataDirectory, QInstance instance) throws SQLException + static void ensureSchema(Path dataDirectory, QInstance instance, Integer port) throws SQLException { try(Connection connection = DriverManager.getConnection( "jdbc:h2:file:" + CoreData.h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); @@ -78,6 +78,11 @@ final class DemoQBitData statement.execute("MERGE INTO workflow_step (id, workflow_revision_id, step_no, workflow_step_type_name, summary, input_values_json) " + "KEY (id) VALUES (1, 1, 1, 'updateInputRecordField', 'Mark order reviewed', " + "'{\"fieldName\":\"status\",\"value\":\"REVIEWED\"}')"); + statement.execute("MERGE INTO webhook (id, name, url, active_status_id, health_status_id) KEY (id) " + + "VALUES (1, 'Local Order Receiver', 'http://127.0.0.1:" + port + "/demo/order-webhook', 1, 1)"); + statement.execute("MERGE INTO webhook_subscription (id, webhook_id, webhook_event_type_name, " + + "active_status_id, api_name, api_version, create_date) KEY (id) " + + "VALUES (1, 1, 'orderStored', 1, 'none', 'none', CURRENT_TIMESTAMP)"); } } diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java index 96edab5..938a12f 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoQBits.java @@ -108,7 +108,8 @@ final class DemoQBits .produce(instance).addSelfToInstance(instance); WebhooksRegistry.ofOrWithNew(instance).registerWebhookEventType(new WebhookEventType() .withName("orderStored").withLabel("Order Stored") - .withCategory(WebhookEventCategory.STORE).withTableName("order")); + .withCategory(WebhookEventCategory.STORE).withTableName("order") + .withCustomizer(new QCodeReference(DemoOrderWebhookContent.class))); new WorkflowsQBitProducer() .withQBitConfig(new WorkflowsQBitConfig().withTableMetaDataCustomizer(tableCustomizer)) diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java index cfb90d9..e7cb4ae 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java @@ -7,8 +7,11 @@ package com.kingsrook.qqq.all; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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.session.QSession; import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; @@ -17,6 +20,16 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface { public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + private static final List DEMO_TABLES = List.of("customer", "order", "orderLine", "product", "address"); + + static void protectDemoTables(QInstance instance) + { + for(String tableName : DEMO_TABLES) + { + instance.getTable(tableName).setPermissionRules(new QPermissionRules() + .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)); + } + } @Override public void customizeSession(QInstance instance, QSession session, Map context) @@ -26,6 +39,14 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface String userId = admin ? "core:admin" : "core:demo"; session.getUser().setIdReference(userId); session.getUser().setFullName(name); + for(String tableName : DEMO_TABLES) + { + session.withPermission(tableName + ".read"); + if(admin) + { + session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete"); + } + } session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test"))); } } diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java index f143fa4..f15d246 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java @@ -140,6 +140,9 @@ public final class QqqAllApplication extends AbstractQQQApplication if(includeDemoQbits) { DemoQBits.addCore(instance, "h2"); + DemoUsers.protectDemoTables(instance); + QJavalinMetaData.ofOrWithNew(instance) + .withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class)); } return instance; } diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java index 3cee6fa..76d4c99 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java @@ -65,6 +65,18 @@ class CoreApplicationTest assertEquals(200, dashboard.statusCode()); assertTrue(dashboard.body().contains("html")); + HttpResponse demoRead = client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/order/query")) + .header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, demoRead.statusCode(), demoRead.body()); + HttpResponse demoInsert = insertOrder(client, port, DemoUsers.DEMO_SESSION, "ORD-DENIED"); + assertEquals(403, demoInsert.statusCode()); + HttpResponse adminInsert = insertOrder(client, port, DemoUsers.ADMIN_SESSION, "ORD-ADMIN"); + assertEquals(200, adminInsert.statusCode(), adminInsert.body()); + QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); try { @@ -90,6 +102,18 @@ class CoreApplicationTest .withValue("customerId", 2).withValue("status", "NEW")))); waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0); waitFor(() -> !new QueryAction().execute(new QueryInput("processTrace")).getRecords().isEmpty()); + waitFor(() -> !new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().isEmpty()); + + RunProcessInput sendWebhook = new RunProcessInput(); + sendWebhook.setProcessName("SendWebhookEvent"); + sendWebhook.addValue("webhookId", 1); + sendWebhook.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + new RunProcessAction().execute(sendWebhook); + HttpResponse receipts = client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts")) + .build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, receipts.statusCode(), receipts.body()); + assertTrue(receipts.body().contains("ORD-ADMIN"), receipts.body()); RunProcessInput workflow = new RunProcessInput(); workflow.setProcessName("RunRecordWorkflow"); @@ -107,6 +131,18 @@ class CoreApplicationTest } } + private static HttpResponse insertOrder(HttpClient client, Integer port, String sessionId, String orderNo) + throws Exception + { + return client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/data/order/")) + .header("Cookie", "sessionId=" + sessionId) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{\"orderNo\":\"" + orderNo + + "\",\"customerId\":1,\"status\":\"NEW\"}")) + .build(), HttpResponse.BodyHandlers.ofString()); + } + private static void waitFor(java.util.concurrent.Callable condition) throws Exception { long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); -- 2.54.0 From 82c4f3de568f69581741690cd41f7d1a5ef5091b Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 26 Sep 2026 09:52:37 -0500 Subject: [PATCH 3/4] fix(qbits): restrict core webhook administration (#6) --- qqq-all-app/README.md | 2 +- .../qqq/all/DemoOrderWebhookReceiver.java | 2 +- .../java/com/kingsrook/qqq/all/DemoUsers.java | 29 ++++++++++++ .../qqq/all/CoreApplicationTest.java | 44 ++++++++++++++++--- .../com/kingsrook/qqq/all/FullQBitsTest.java | 3 ++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md index 8bc016e..b74f8c7 100644 --- a/qqq-all-app/README.md +++ b/qqq-all-app/README.md @@ -11,7 +11,7 @@ The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0 Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. Admin can read and write the customer, order, order-line, product, and address tables; Demo User can read them but cannot write them. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API. -The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its loopback receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent`, and inspect `/demo/order-webhook-receipts` to see the delivered payload. The receiver is available only in the loopback-bound core profile. +The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its loopback receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent` as Admin, and inspect `/demo/order-webhook-receipts` to see the delivered payload. Demo User can read webhook records but cannot edit their configuration or invoke the delivery process. The receiver is available only in the loopback-bound core profile. ## Full profile diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java index b51186a..2c56802 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoOrderWebhookReceiver.java @@ -47,7 +47,7 @@ public class DemoOrderWebhookReceiver implements QJavalinRouteProviderInterface { RECEIPTS.removeFirst(); } - context.status(204); + context.status(200).result("ok"); }); get("/demo/order-webhook-receipts", context -> context.json(List.copyOf(RECEIPTS))); }); diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java index e7cb4ae..11d425b 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java @@ -21,6 +21,10 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; private static final List DEMO_TABLES = List.of("customer", "order", "orderLine", "product", "address"); + private static final List WEBHOOK_TABLES = List.of("webhook", "webhookSubscription", + "webhookEvent", "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter"); + private static final List WEBHOOK_PROCESSES = List.of("SendWebhookEvent", + "SendTestEventToWebhook", "ManageWebhookHealth"); static void protectDemoTables(QInstance instance) { @@ -29,6 +33,16 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface instance.getTable(tableName).setPermissionRules(new QPermissionRules() .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)); } + for(String tableName : WEBHOOK_TABLES) + { + instance.getTable(tableName).setPermissionRules(new QPermissionRules() + .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)); + } + for(String processName : WEBHOOK_PROCESSES) + { + instance.getProcess(processName).setPermissionRules(new QPermissionRules() + .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)); + } } @Override @@ -47,6 +61,21 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete"); } } + for(String tableName : WEBHOOK_TABLES) + { + session.withPermission(tableName + ".read"); + if(admin) + { + session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete"); + } + } + if(admin) + { + for(String processName : WEBHOOK_PROCESSES) + { + session.withPermission(processName + ".hasAccess"); + } + } session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test"))); } } diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java index 76d4c99..a7d853c 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.Map; +import com.kingsrook.qbits.webhooks.model.WebhookEventStatus; 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.processes.RunProcessAction; @@ -74,8 +75,18 @@ class CoreApplicationTest assertEquals(200, demoRead.statusCode(), demoRead.body()); HttpResponse demoInsert = insertOrder(client, port, DemoUsers.DEMO_SESSION, "ORD-DENIED"); assertEquals(403, demoInsert.statusCode()); + assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION, + "webhook", "{\"url\":\"http://127.0.0.1:1/\"}").statusCode()); + assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION, + "webhookSubscription", "{\"activeStatusId\":3}").statusCode()); + assertEquals(403, runWebhookProcess(client, port, DemoUsers.DEMO_SESSION).statusCode()); + assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION, + "webhook", "{\"url\":\"http://127.0.0.1:" + port + "/demo/order-webhook\"}").statusCode()); + assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION, + "webhookSubscription", "{\"activeStatusId\":1}").statusCode()); HttpResponse adminInsert = insertOrder(client, port, DemoUsers.ADMIN_SESSION, "ORD-ADMIN"); assertEquals(200, adminInsert.statusCode(), adminInsert.body()); + assertEquals(200, runWebhookProcess(client, port, DemoUsers.ADMIN_SESSION).statusCode()); QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); try @@ -104,16 +115,15 @@ class CoreApplicationTest waitFor(() -> !new QueryAction().execute(new QueryInput("processTrace")).getRecords().isEmpty()); waitFor(() -> !new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().isEmpty()); - RunProcessInput sendWebhook = new RunProcessInput(); - sendWebhook.setProcessName("SendWebhookEvent"); - sendWebhook.addValue("webhookId", 1); - sendWebhook.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); - new RunProcessAction().execute(sendWebhook); HttpResponse receipts = client.send(HttpRequest.newBuilder() .uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts")) .build(), HttpResponse.BodyHandlers.ofString()); assertEquals(200, receipts.statusCode(), receipts.body()); - assertTrue(receipts.body().contains("ORD-ADMIN"), receipts.body()); + waitFor(() -> client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts")) + .build(), HttpResponse.BodyHandlers.ofString()).body().contains("ORD-ADMIN")); + waitFor(() -> new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().stream() + .anyMatch(event -> WebhookEventStatus.DELIVERED.getId().equals(event.getValueInteger("eventStatusId")))); RunProcessInput workflow = new RunProcessInput(); workflow.setProcessName("RunRecordWorkflow"); @@ -131,6 +141,28 @@ class CoreApplicationTest } } + private static HttpResponse updateWebhook(HttpClient client, Integer port, String sessionId, + String tableName, String body) throws Exception + { + return client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/data/" + tableName + "/1")) + .header("Cookie", "sessionId=" + sessionId) + .header("Content-Type", "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(), HttpResponse.BodyHandlers.ofString()); + } + + private static HttpResponse runWebhookProcess(HttpClient client, Integer port, String sessionId) + throws Exception + { + return client.send(HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/processes/SendWebhookEvent/run?webhookId=1")) + .header("Cookie", "sessionId=" + sessionId) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(), HttpResponse.BodyHandlers.ofString()); + } + private static HttpResponse insertOrder(HttpClient client, Integer port, String sessionId, String orderNo) throws Exception { diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java index 74f8809..b796598 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java @@ -12,6 +12,7 @@ import java.util.stream.Collectors; import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig; import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -40,6 +41,8 @@ class FullQBitsTest assertNotNull(instance.getProcess("SFTPImportFileSyncProcess")); assertNotNull(instance.getTable("SFTPImportSourceFileTable")); assertEquals("sftp", instance.getTable("SFTPImportSourceFileTable").getBackendName()); + assertEquals(PermissionLevel.NOT_PROTECTED, instance.getTable("webhook").getPermissionRules().getLevel()); + assertEquals(PermissionLevel.NOT_PROTECTED, instance.getProcess("SendWebhookEvent").getPermissionRules().getLevel()); QuickSearchQBitConfig search = (QuickSearchQBitConfig) instance.getQBits().values().stream() .filter(qbit -> "quick-search".equals(qbit.getArtifactId())).findFirst().orElseThrow().getConfig(); assertEquals("customer", search.getSearchableTables().getFirst().getTableName()); -- 2.54.0 From 04f4e74253d034229d0064330083f3a69d6a89ca Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 26 Sep 2026 09:59:44 -0500 Subject: [PATCH 4/4] fix(qbits): protect full-profile webhook operations (#6) --- infra/postgres/03-webhook-permissions.sql | 33 +++++++++ qqq-all-app/README.md | 2 +- .../java/com/kingsrook/qqq/all/DemoUsers.java | 5 ++ .../qqq/all/FullProfileApplication.java | 1 + .../com/kingsrook/qqq/all/FullQBitsTest.java | 68 ++++++++++++++++++- 5 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 infra/postgres/03-webhook-permissions.sql diff --git a/infra/postgres/03-webhook-permissions.sql b/infra/postgres/03-webhook-permissions.sql new file mode 100644 index 0000000..db7e4cd --- /dev/null +++ b/infra/postgres/03-webhook-permissions.sql @@ -0,0 +1,33 @@ +-- Add the webhook qbit's protected resources to the demo OIDC roles. +-- Runs after C6's 02-auth-and-roles.sql during PostgreSQL initialization. +INSERT INTO permission (name, object_type, object_label) +SELECT table_name || '.' || action, 'table', table_name +FROM (VALUES + ('webhook'), ('webhookSubscription'), ('webhookEvent'), + ('webhookEventContent'), ('webhookEventSendLog'), + ('scheduledJob'), ('scheduledJobParameter') +) AS tables(table_name) +CROSS JOIN (VALUES ('read'), ('insert'), ('edit'), ('delete')) AS actions(action) +ON CONFLICT (name) DO NOTHING; + +INSERT INTO permission (name, object_type, object_label) +SELECT process_name || '.hasAccess', 'process', process_name +FROM (VALUES + ('SendWebhookEvent'), ('SendTestEventToWebhook'), ('ManageWebhookHealth') +) AS processes(process_name) +ON CONFLICT (name) DO NOTHING; + +INSERT INTO role_permission_int (role_id, permission_id) +SELECT role.id, permission.id +FROM role CROSS JOIN permission +WHERE (role.name = 'admin' OR (role.name = 'viewer' AND permission.name LIKE '%.read')) + AND (permission.name LIKE 'webhook.%' + OR permission.name LIKE 'webhookSubscription.%' + OR permission.name LIKE 'webhookEvent.%' + OR permission.name LIKE 'webhookEventContent.%' + OR permission.name LIKE 'webhookEventSendLog.%' + OR permission.name LIKE 'scheduledJob.%' + OR permission.name LIKE 'scheduledJobParameter.%' + OR permission.name IN ('SendWebhookEvent.hasAccess', + 'SendTestEventToWebhook.hasAccess', 'ManageWebhookHealth.hasAccess')) +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md index b74f8c7..09a0251 100644 --- a/qqq-all-app/README.md +++ b/qqq-all-app/README.md @@ -31,4 +31,4 @@ Configure the following environment variables before starting the same jar: | Keycloak OIDC | `QQQ_ALL_OIDC_BASE_URL`, `QQQ_ALL_OIDC_CLIENT_ID`, `QQQ_ALL_OIDC_CLIENT_SECRET` | `QQQ_ALL_OIDC_EXTERNAL_BASE_URL` (base URL), `QQQ_ALL_OIDC_SCOPES` (`openid email profile`) | | OpenSearch quick search | `QQQ_ALL_OPENSEARCH_HOST` | `QQQ_ALL_OPENSEARCH_PORT` (9200), `QQQ_ALL_OPENSEARCH_INDEX` (`qqq-all-customers`), `QQQ_ALL_OPENSEARCH_USER`, `QQQ_ALL_OPENSEARCH_PASSWORD`, `QQQ_ALL_OPENSEARCH_SSL` (false) | -The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Quick search is configured for customer `name` and `email`; run `quickSearchFullReindex` after OpenSearch and the customer data are ready. The SFTP integration adds its import source and staging tables plus `SFTPImportFileSyncProcess`; `externalImportFile` remains the direct SFTP file sample. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo records, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this profile expects those resources to exist. +The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Quick search is configured for customer `name` and `email`; run `quickSearchFullReindex` after OpenSearch and the customer data are ready. The SFTP integration adds its import source and staging tables plus `SFTPImportFileSyncProcess`; `externalImportFile` remains the direct SFTP file sample. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. Webhook tables and delivery processes require explicit permissions: the viewer role receives read access, while the admin role can edit destinations and run delivery. The additive `infra/postgres/03-webhook-permissions.sql` grant file runs after C6's role seed. PostgreSQL must also contain the qbit permission tables and OAuth session and redirect-state tables. External database schema, demo records, demo roles, the MinIO bucket, and the service stack are provisioned by the forthcoming compose and seed work in C6; this profile expects those resources to exist. diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java index 11d425b..93b7f6e 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java @@ -33,6 +33,11 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface instance.getTable(tableName).setPermissionRules(new QPermissionRules() .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)); } + protectWebhookResources(instance); + } + + static void protectWebhookResources(QInstance instance) + { for(String tableName : WEBHOOK_TABLES) { instance.getTable(tableName).setPermissionRules(new QPermissionRules() diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java index 7ac3ddf..04d6a85 100644 --- a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/FullProfileApplication.java @@ -65,6 +65,7 @@ final class FullProfileApplication extends AbstractQQQApplication addEsb(instance); DemoQBits.addCore(instance, "postgres"); DemoQBits.addFull(instance, settings); + DemoUsers.protectWebhookResources(instance); EmailMessagingProviderMetaData mail = new EmailMessagingProviderMetaData() .withSmtpServer(settings.get("SMTP_HOST")) diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java index b796598..1460427 100644 --- a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/FullQBitsTest.java @@ -10,13 +10,22 @@ import java.nio.file.Path; import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.session.QSession; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class FullQBitsTest @@ -41,12 +50,65 @@ class FullQBitsTest assertNotNull(instance.getProcess("SFTPImportFileSyncProcess")); assertNotNull(instance.getTable("SFTPImportSourceFileTable")); assertEquals("sftp", instance.getTable("SFTPImportSourceFileTable").getBackendName()); - assertEquals(PermissionLevel.NOT_PROTECTED, instance.getTable("webhook").getPermissionRules().getLevel()); - assertEquals(PermissionLevel.NOT_PROTECTED, instance.getProcess("SendWebhookEvent").getPermissionRules().getLevel()); + for(String tableName : Set.of("webhook", "webhookSubscription", "webhookEvent", + "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter")) + { + assertEquals(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS, + instance.getTable(tableName).getPermissionRules().getLevel(), tableName); + } + for(String processName : Set.of("SendWebhookEvent", "SendTestEventToWebhook", "ManageWebhookHealth")) + { + assertEquals(PermissionLevel.HAS_ACCESS_PERMISSION, + instance.getProcess(processName).getPermissionRules().getLevel(), processName); + } QuickSearchQBitConfig search = (QuickSearchQBitConfig) instance.getQBits().values().stream() .filter(qbit -> "quick-search".equals(qbit.getArtifactId())).findFirst().orElseThrow().getConfig(); assertEquals("customer", search.getSearchableTables().getFirst().getTableName()); assertEquals(Set.of("name", "email"), search.getSearchableTables().getFirst().getFields().stream() .map(field -> field.getFieldName()).collect(Collectors.toSet())); } + + @Test + void viewerPermissionsCannotChangeWebhookDestinationOrSendEvent() throws Exception + { + QInstance instance = new FullProfileApplication(dataDirectory, + FullProfileSettings.from(FullProfileTest.environment())).defineQInstance(); + QSession viewer = new QSession().withPermissions("webhook.read", "webhookSubscription.read"); + QContext.init(instance, viewer); + try + { + assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhook"), + "webhook", TablePermissionSubType.EDIT)); + assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"), + "webhookSubscription", TablePermissionSubType.EDIT)); + assertThrows(QPermissionDeniedException.class, + () -> PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent")); + } + finally + { + QContext.clear(); + } + } + + @Test + void adminPermissionsCanChangeWebhookDestinationAndSendEvent() throws Exception + { + QInstance instance = new FullProfileApplication(dataDirectory, + FullProfileSettings.from(FullProfileTest.environment())).defineQInstance(); + QSession admin = new QSession().withPermissions("webhook.edit", "webhookSubscription.edit", + "SendWebhookEvent.hasAccess"); + QContext.init(instance, admin); + try + { + assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhook"), + "webhook", TablePermissionSubType.EDIT)); + assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"), + "webhookSubscription", TablePermissionSubType.EDIT)); + PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent"); + } + finally + { + QContext.clear(); + } + } } -- 2.54.0