From 0d876645b9c6d482be463e98255bca60e702825a Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 5 Nov 2026 09:07:08 -0600 Subject: [PATCH 1/2] feat: per-session custom apps, a local widget backend and restricted permissions on QQQ 4.1 Port the host-proven Custom Apps work onto upstream develop (9ba3336f): - LOCAL_WIDGET backend for custom screens that show a widget over local data, and the allowed-component-hosts and backend-name settings in CustomAppsQBitConfig (https hosts only, lower-cased, no embedded credentials); - CustomAppsMetaDataActionCustomizer, which gives each session exactly the containers, apps and widgets whose ".hasAccess" permission it holds; - permission widgets for custom apps and custom app containers (the roles that hold the permission, as a child record list) and the metadata producer changes behind them; - CustomAppsPostgresTest and TestDdl: the schema owner creates the tables, the application runs as a DML-only role, on a real PostgreSQL 17 (Testcontainers, UTC surefire argLine). Build against the published QQQ 4.1 core (qqq-bom-pom 4.1.0-RC.21) and qbit-user-role-permissions 0.33.0-RC.1 (canonical registry and Maven Central; its core dependency is excluded so the QQQ BOM governs). The only SNAPSHOT left is the project's own development revision. Every qqq class the sources import exists in the published RC.21 and URP jars and every QQQ-style call name resolves in them (read-only javap); not compiled here. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 51 ++- .../customapps/CustomAppsQBitConfig.java | 60 +++ ...inerPermissionsWidgetMetaDataProducer.java | 68 +++ ...mAppPermissionsWidgetMetaDataProducer.java | 68 +++ .../CustomAppsDynamicMetaDataProducer.java | 147 ++++++- .../CustomAppsMetaDataActionCustomizer.java | 117 ++++++ .../qbits/customapps/model/CustomApp.java | 38 +- .../model/CustomAppBackendEnum.java | 3 +- .../customapps/CustomAppsPostgresTest.java | 395 ++++++++++++++++++ .../kingsrook/qbits/customapps/TestDdl.java | 87 ++++ 10 files changed, 1017 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java create mode 100644 src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java create mode 100644 src/test/java/com/kingsrook/qbits/customapps/TestDdl.java diff --git a/pom.xml b/pom.xml index 3907069..eda0d5f 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,10 @@ 21 true true + + + + 0.33.0-RC.1 @@ -56,6 +60,16 @@ 4.0.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -Duser.timezone=UTC + + + src/main/resources @@ -87,7 +101,9 @@ com.kingsrook.qqq qqq-bom-pom - 0.40.0 + + 4.1.0-RC.21 pom import @@ -98,12 +114,17 @@ com.kingsrook.qqq qqq-backend-core - 0.40.0 com.kingsrook.qbits qbit-user-role-permissions - 0.31.0 + ${urp.version} + + + com.kingsrook.qqq + qqq-backend-core + + com.kingsrook.qqq @@ -115,11 +136,6 @@ qqq-backend-module-rdbms test - - com.kingsrook.qqq - qqq-frontend-material-dashboard - 0.40.0 - org.junit.jupiter @@ -129,7 +145,24 @@ org.assertj assertj-core - 3.27.3 + 3.27.7 + test + + + com.kingsrook.qqq + qqq-backend-module-postgres + test + + + com.h2database + h2 + 2.2.220 + test + + + org.testcontainers + testcontainers + 2.0.3 test diff --git a/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java b/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java index 67ee496..9203aca 100644 --- a/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/customapps/CustomAppsQBitConfig.java @@ -44,6 +44,10 @@ public class CustomAppsQBitConfig implements QBitConfig private boolean includeApiVersions = false; private String componentSourceUrl; + private String backendName; + + private java.util.Set allowedComponentHosts = new java.util.LinkedHashSet<>(); + /******************************************************************************* @@ -63,6 +67,7 @@ public class CustomAppsQBitConfig implements QBitConfig @Override public void validate(QInstance qInstance, List errors) { + assertCondition(backendName != null && !backendName.isBlank(), "backendName is required for the Custom Apps QBit", errors); if(includeApiVersions && !apiMiddlewareModuleAvailable) { errors.add("Custom Apps QBit is configured to includeApiVersions, but the qqq-middleware-api module is available."); @@ -173,4 +178,59 @@ public class CustomAppsQBitConfig implements QBitConfig return (this); } + + + /******************************************************************************* + ** The backend the qbit's tables live on. + *******************************************************************************/ + @Override + public String getDefaultBackendNameForTables() + { + return (backendName); + } + + + + /******************************************************************************* + ** Getter for backendName + *******************************************************************************/ + public String getBackendName() + { + return (this.backendName); + } + + + + /******************************************************************************* + ** Fluent setter for backendName + *******************************************************************************/ + public CustomAppsQBitConfig withBackendName(String backendName) + { + this.backendName = backendName; + return (this); + } + + + + /******************************************************************************* + ** Getter for allowedComponentHosts (lower-case host names an https component + ** bundle may be loaded from; empty means paths on this application only) + *******************************************************************************/ + public java.util.Set getAllowedComponentHosts() + { + return (this.allowedComponentHosts); + } + + + + /******************************************************************************* + ** Fluent setter for allowedComponentHosts + *******************************************************************************/ + public CustomAppsQBitConfig withAllowedComponentHosts(java.util.Collection hosts) + { + this.allowedComponentHosts = new java.util.LinkedHashSet<>(); + hosts.forEach(host -> this.allowedComponentHosts.add(host.toLowerCase())); + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java new file mode 100644 index 0000000..52eff51 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppContainerPermissionsWidgetMetaDataProducer.java @@ -0,0 +1,68 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customapps.metadata; + + +import com.kingsrook.qbits.customapps.model.CustomAppContainer; +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.metadata.MetaDataProducer; +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.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; + + +/******************************************************************************* + ** The "users" section of a custom app container: the roles that hold the container's access + ** permission (rows of the permission-to-role table), as a child record list. + *******************************************************************************/ +public class CustomAppContainerPermissionsWidgetMetaDataProducer extends MetaDataProducer +{ + public static final String NAME = "customAppContainerPermissions"; + public static final String JOIN_NAME = "customAppContainerRolePermission"; + + + + /*************************************************************************** + ** The widget, and (as a side effect, once) its join. + ***************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance qInstance) throws QException + { + if(qInstance.getJoin(JOIN_NAME) == null) + { + qInstance.addJoin(new QJoinMetaData() + .withName(JOIN_NAME) + .withLeftTable(CustomAppContainer.TABLE_NAME) + .withRightTable("rolePermissionInt") + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("permissionId", "permissionId"))); + } + QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(qInstance.getJoin(JOIN_NAME)) + .withLabel("Roles with access") + .getWidgetMetaData(); + widget.setName(NAME); + return (widget); + } +} diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java new file mode 100644 index 0000000..c3ef105 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppPermissionsWidgetMetaDataProducer.java @@ -0,0 +1,68 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customapps.metadata; + + +import com.kingsrook.qbits.customapps.model.CustomApp; +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.metadata.MetaDataProducer; +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.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; + + +/******************************************************************************* + ** The "users" section of a custom app: the roles that hold the app's access + ** permission (rows of the permission-to-role table), as a child record list. + *******************************************************************************/ +public class CustomAppPermissionsWidgetMetaDataProducer extends MetaDataProducer +{ + public static final String NAME = "customAppPermissions"; + public static final String JOIN_NAME = "customAppRolePermission"; + + + + /*************************************************************************** + ** The widget, and (as a side effect, once) its join. + ***************************************************************************/ + @Override + public QWidgetMetaData produce(QInstance qInstance) throws QException + { + if(qInstance.getJoin(JOIN_NAME) == null) + { + qInstance.addJoin(new QJoinMetaData() + .withName(JOIN_NAME) + .withLeftTable(CustomApp.TABLE_NAME) + .withRightTable("rolePermissionInt") + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("permissionId", "permissionId"))); + } + QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(qInstance.getJoin(JOIN_NAME)) + .withLabel("Roles with access") + .getWidgetMetaData(); + widget.setName(NAME); + return (widget); + } +} diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java index 882c51c..67b477f 100644 --- a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsDynamicMetaDataProducer.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import com.kingsrook.qbits.customapps.CustomAppsQBitConfig; import com.kingsrook.qbits.customapps.model.CustomApp; import com.kingsrook.qbits.customapps.model.CustomAppBackendConfig; import com.kingsrook.qbits.customapps.model.CustomAppBackendEnum; @@ -38,6 +39,8 @@ import com.kingsrook.qbits.customapps.utils.CustomAppsUtils; import com.kingsrook.qbits.customapps.widgets.LookerWidgetRenderer; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -76,6 +79,42 @@ public class CustomAppsDynamicMetaDataProducer * ***************************************************************************/ public CustomAppData getCustomAppList(QInstance qInstance) throws QException + { + //////////////////////////////////////////////////////////////////////////////// + // capture the viewer now: the build itself runs with the system session set // + //////////////////////////////////////////////////////////////////////////////// + QSession viewer = QContext.getQSession(); + return (getCustomAppList(qInstance, name -> viewer != null && viewer.hasPermission(name + ".hasAccess"))); + } + + + + /*************************************************************************** + ** Build the apps for whoever the predicate admits (by app/container name). + ** The definition rows are read as the system user (they are configuration, + ** not data of the viewer); what the viewer may see is decided only by the + ** predicate, which the metadata customizer ties to the session's permissions. + ***************************************************************************/ + public CustomAppData getCustomAppList(QInstance qInstance, java.util.function.Predicate permitted) throws QException + { + QSession original = QContext.getQSession(); + try + { + QContext.setQSession(new QSystemUserSession()); + return (buildCustomAppList(qInstance, permitted, original)); + } + finally + { + QContext.setQSession(original); + } + } + + + + /*************************************************************************** + ** The build proper, with the system session in place for reads. + ***************************************************************************/ + private CustomAppData buildCustomAppList(QInstance qInstance, java.util.function.Predicate permitted, QSession viewer) throws QException { /////////////////////////////////////////////////// // look up data needed to set up the custom apps // @@ -152,7 +191,7 @@ public class CustomAppsDynamicMetaDataProducer ///////////////////////////////////////////////////// // make sure user has permission to this app // //////////////////////////////////////////////////// - if(!QContext.getQSession().hasPermission(appName + ".hasAccess")) + if(!permitted.test(appName)) { continue; } @@ -161,8 +200,21 @@ public class CustomAppsDynamicMetaDataProducer // create a custom widget for this app // ///////////////////////////////////////// String widgetName = appName + "Widget"; - QWidgetMetaDataInterface widget = buildWidget(qInstance, app, customAppBackendConfigMap, widgetName); - CustomAppIcon appIcon = customAppIconMap.get(app.getCustomAppIconId()); + QWidgetMetaDataInterface widget; + try + { + widget = buildWidget(qInstance, app, customAppBackendConfigMap, widgetName); + } + catch(IllegalArgumentException e) + { + /////////////////////////////////////////////////////////////////////// + // one invalid row must not take the other apps (or startup) with it; // + // it is simply not offered // + /////////////////////////////////////////////////////////////////////// + LOG.warn("Custom app skipped: its definition is not valid", logPair("app", app.getName()), logPair("reason", e.getMessage())); + continue; + } + CustomAppIcon appIcon = customAppIconMap.get(app.getCustomAppIconId()); QAppMetaData appMetaData = new QAppMetaData() .withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(appName).withDenyBehavior(DenyBehavior.HIDDEN)) @@ -217,7 +269,7 @@ public class CustomAppsDynamicMetaDataProducer ///////////////////////////////////////////////////// // make sure user has permission to this container // ///////////////////////////////////////////////////// - if(!QContext.getQSession().hasPermission(containerName + ".hasAccess")) + if(!permitted.test(containerName)) { continue; } @@ -248,6 +300,8 @@ public class CustomAppsDynamicMetaDataProducer { case LOOKER: return (buildLookerWidget(qInstance, customApp, widgetName)); + case LOCAL_WIDGET: + return (localWidget(qInstance, customApp)); default: throw new IllegalArgumentException("Unknown custom app backend: " + customAppBackendConfig.getCustomAppBackendId()); } @@ -256,6 +310,34 @@ public class CustomAppsDynamicMetaDataProducer + /******************************************************************************* + ** a custom app that shows a widget the application itself defines (a record + ** list, a chart, ...): the screen is composed from existing, permissioned + ** metadata, so no code or markup comes from the data, and the widget keeps its + ** own permission rules. Only widgets that exist on the instance can be named, + ** and never this qbit's own generated ones. + *******************************************************************************/ + private QWidgetMetaDataInterface localWidget(QInstance qInstance, CustomApp customApp) + { + String name = customApp.getWidgetName(); + if(!StringUtils.hasContent(name)) + { + throw new IllegalArgumentException("a local widget app needs a widget name"); + } + QWidgetMetaDataInterface widget = qInstance.getWidget(name); + if(widget == null) + { + throw new IllegalArgumentException("widget " + name + " is not defined on this application"); + } + if(name.startsWith("qca")) + { + throw new IllegalArgumentException("widget " + name + " is generated by custom apps and cannot be chosen"); + } + return (widget); + } + + + /******************************************************************************* ** builds a widget to be used for a customapps app *******************************************************************************/ @@ -271,10 +353,19 @@ public class CustomAppsDynamicMetaDataProducer componentSourceUrl = "/dynamic-qfmd-components/q-embed-looker-dashboards.bundle.js"; } + if(!isAllowedComponentSource(componentSourceUrl)) + { + throw new IllegalArgumentException("the component source must be a path on this application (or an https address the configuration allows)"); + } + ////////////////////////////// // add the dashboard to url // ///////////////////////// Integer dashboardId = customApp.getLookerDashboardId(); + if(dashboardId == null) + { + throw new IllegalArgumentException("a Looker app needs a dashboard id"); + } componentSourceUrl += "?dashboardId=" + dashboardId; Map defaultValues = MapBuilder.of( @@ -287,7 +378,7 @@ public class CustomAppsDynamicMetaDataProducer QWidgetMetaData lookerWidget = new QWidgetMetaData() .withName(widgetName) - .withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(widgetName + ".hasAccess").withDenyBehavior(DenyBehavior.HIDDEN)) + .withPermissionRules(qInstance.getDefaultPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(customApp.getName() == null ? widgetName : CustomAppsUtils.getCamelCaseName(customApp.getName(), CustomApp.TABLE_NAME)).withDenyBehavior(DenyBehavior.HIDDEN)) .withType(WidgetType.CUSTOM_COMPONENT.getType()) .withCodeReference(new QCodeReference(LookerWidgetRenderer.class)) .withGridColumns(12) @@ -304,5 +395,51 @@ public class CustomAppsDynamicMetaDataProducer + /******************************************************************************* + ** The component bundle is code the browser runs, so its source is never taken + ** from data: a path served by this application, or an https address whose host + ** the configuration lists. Anything else (script or data URLs, protocol + ** relative addresses, other hosts) is refused. + *******************************************************************************/ + public boolean isAllowedComponentSource(String url) + { + if(url == null || url.chars().anyMatch(c -> c < 0x21 || c > 0x7e)) + { + return (false); + } + if(url.startsWith("/") && !url.startsWith("//") && !url.contains("\\") && !url.contains("..")) + { + return (true); + } + try + { + java.net.URI uri = new java.net.URI(url); + CustomAppsQBitConfig config = configFor(QContext.getQInstance()); + return ("https".equals(uri.getScheme()) && uri.getUserInfo() == null && uri.getHost() != null + && config != null && config.getAllowedComponentHosts().contains(uri.getHost().toLowerCase())); + } + catch(java.net.URISyntaxException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** The qbit's configuration on the instance, if installed. + *******************************************************************************/ + static CustomAppsQBitConfig configFor(QInstance qInstance) + { + if(qInstance == null || qInstance.getQBits() == null) + { + return (null); + } + return (qInstance.getQBits().values().stream().map(qBit -> qBit.getConfig()).filter(CustomAppsQBitConfig.class::isInstance) + .map(CustomAppsQBitConfig.class::cast).findFirst().orElse(null)); + } + + + public record CustomAppData(List containerList, Map positionMap, Map appMap, Map widgetMap) {} } diff --git a/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java new file mode 100644 index 0000000..15e1cec --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/customapps/metadata/CustomAppsMetaDataActionCustomizer.java @@ -0,0 +1,117 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customapps.metadata; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.metadata.DefaultNoopMetaDataActionCustomizer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; +import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; +import com.kingsrook.qqq.backend.core.model.metadata.frontend.AppTreeNode; +import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Offers the declared custom apps to the frontend, per session: each request's + ** metadata gets exactly the containers, apps and widgets whose + ** "<name>.hasAccess" permission the session holds, ahead of a container named + ** by its "place before" setting. Everything else is the application's own + ** metadata customizer behaviour, left unchanged (this extends the standard no-op + ** customizer, so an application that needs its own rules extends this class). + *******************************************************************************/ +public class CustomAppsMetaDataActionCustomizer extends DefaultNoopMetaDataActionCustomizer +{ + private static final QLogger LOG = QLogger.getLogger(CustomAppsMetaDataActionCustomizer.class); + + + + /*************************************************************************** + ** Add the permitted custom apps to the finished metadata. + ***************************************************************************/ + @Override + public void postProcess(MetaDataOutput metaDataOutput) throws QException + { + super.postProcess(metaDataOutput); + + CustomAppsDynamicMetaDataProducer.CustomAppData data; + try + { + data = new CustomAppsDynamicMetaDataProducer().getCustomAppList(QContext.getQInstance()); + } + catch(QException | RuntimeException e) + { + LOG.warn("Custom apps could not be read; none are offered", logPair("error", e.getClass().getSimpleName())); + return; + } + + MetaDataInput input = new MetaDataInput(); + for(QWidgetMetaDataInterface widget : data.widgetMap().values()) + { + if(QContext.getQInstance().getWidget(widget.getName()) != null) + { + metaDataOutput.getWidgets().put(widget.getName(), new QFrontendWidgetMetaData(input, widget)); + } + } + for(QAppMetaData app : data.appMap().values()) + { + metaDataOutput.getApps().put(app.getName(), new QFrontendAppMetaData(app, metaDataOutput)); + } + + List tree = new ArrayList<>(metaDataOutput.getAppTree()); + for(QAppMetaData container : data.containerList()) + { + AppTreeNode node = new AppTreeNode(container); + for(var section : container.getSections()) + { + for(String appName : section.getApps()) + { + QAppMetaData child = data.appMap().get(appName); + if(child != null) + { + node.addChild(new AppTreeNode(child)); + } + } + } + metaDataOutput.getApps().put(container.getName(), new QFrontendAppMetaData(container, metaDataOutput)); + int position = tree.size(); + String before = data.positionMap().get(container.getName()); + for(int i = 0; before != null && i < tree.size(); i++) + { + if(before.equals(tree.get(i).getName())) + { + position = i; + break; + } + } + tree.add(position, node); + } + metaDataOutput.setAppTree(tree); + } +} diff --git a/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java b/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java index 3a9cfeb..f280134 100644 --- a/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java +++ b/src/main/java/com/kingsrook/qbits/customapps/model/CustomApp.java @@ -77,7 +77,7 @@ public class CustomApp extends QRecordEntity implements Serializable .withRecordLabelFormat("%s") .withRecordLabelFields("name") .withSection(SectionFactory.defaultT1("id")) - .withSection(SectionFactory.defaultT2("name", "customAppIconId", "customAppBackendConfigId", "customAppSectionId", "sequenceNo", "lookerDashboardId", "permissionId")) + .withSection(SectionFactory.defaultT2("name", "customAppIconId", "customAppBackendConfigId", "customAppSectionId", "sequenceNo", "lookerDashboardId", "widgetName", "permissionId")) .withSection(new QFieldSection("users", new QIcon().withName("person"), Tier.T2).withWidgetName("customAppPermissions")) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")) @@ -115,9 +115,12 @@ public class CustomApp extends QRecordEntity implements Serializable @QField(isEditable = false, possibleValueSourceName = Permission.TABLE_NAME) private Integer permissionId; - @QField(isRequired = true) + @QField(label = "Looker Dashboard Id") private Integer lookerDashboardId; + @QField(label = "Local Widget", maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + private String widgetName; + @QField(isEditable = false) private Instant createDate; @@ -453,4 +456,35 @@ public class CustomApp extends QRecordEntity implements Serializable return (this); } + + + /******************************************************************************* + ** Getter for widgetName + *******************************************************************************/ + public String getWidgetName() + { + return (this.widgetName); + } + + + + /******************************************************************************* + ** Setter for widgetName + *******************************************************************************/ + public void setWidgetName(String widgetName) + { + this.widgetName = widgetName; + } + + + + /******************************************************************************* + ** Fluent setter for widgetName + *******************************************************************************/ + public CustomApp withWidgetName(String widgetName) + { + this.widgetName = widgetName; + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java b/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java index a14f91a..4ce91b7 100644 --- a/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java +++ b/src/main/java/com/kingsrook/qbits/customapps/model/CustomAppBackendEnum.java @@ -33,7 +33,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta public enum CustomAppBackendEnum implements PossibleValueEnum { LOOKER(1, "Looker"), - URL(2, "URL"); + URL(2, "URL"), + LOCAL_WIDGET(3, "Local widget"); private final Integer id; private final String label; diff --git a/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java b/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java new file mode 100644 index 0000000..477813c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/customapps/CustomAppsPostgresTest.java @@ -0,0 +1,395 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customapps; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.customapps.metadata.CustomAppsDynamicMetaDataProducer; +import com.kingsrook.qbits.customapps.metadata.CustomAppsMetaDataActionCustomizer; +import com.kingsrook.qbits.customapps.model.CustomApp; +import com.kingsrook.qbits.customapps.model.CustomAppBackendEnum; +import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitConfig; +import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitProducer; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +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.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; +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.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +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.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.ProvidedOrSuppliedTableConfig; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Custom apps on a real PostgreSQL 17: the schema owner creates the tables, the + ** application runs as a DML-only role, apps declared as rows reach the frontend + ** metadata per session, a custom screen shows a widget over local data, and the + ** apps' permissions (rows of the permission table the roles qbit manages) decide + ** who sees and renders it. + *******************************************************************************/ +class CustomAppsPostgresTest +{ + private static final String OWNER = "ca_owner"; + private static final String RUNTIME = "ca_runtime"; + private static final String BACKEND = "customAppsPg"; + private static final String ITEMS = "demoItem"; + private static final String WIDGET = "demoItemCountWidget"; + + private static GenericContainer postgres; + private static String host; + private static int port; + + + + /******************************************************************************* + ** PG17 with an owner-created schema (derived from the installed metadata) and a + ** DML-only runtime role. + *******************************************************************************/ + @BeforeAll + static void start() throws Exception + { + postgres = new GenericContainer<>("postgres:17-alpine") + .withEnv("POSTGRES_PASSWORD", "super-synthetic") + .withExposedPorts(5432) + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(java.time.Duration.ofMinutes(2))); + postgres.start(); + host = postgres.getHost(); + port = postgres.getMappedPort(5432); + + try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement()) + { + statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'"); + statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'"); + statement.execute("CREATE DATABASE customapps OWNER " + OWNER); + statement.execute("REVOKE ALL ON DATABASE customapps FROM PUBLIC"); + statement.execute("GRANT CONNECT ON DATABASE customapps TO " + RUNTIME); + } + + QInstance scratch = instance(OWNER, "owner-synthetic"); + try(Connection owner = connect(OWNER, "owner-synthetic", "customapps"); Statement statement = owner.createStatement()) + { + statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC"); + statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME); + for(QTableMetaData table : scratch.getTables().values()) + { + if(BACKEND.equals(table.getBackendName())) + { + statement.execute(TestDdl.create(table)); + statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON \"" + table.getName() + "\" TO " + RUNTIME); + } + } + } + } + + + + /******************************************************************************* + ** Stop the database. + *******************************************************************************/ + @AfterAll + static void stop() + { + if(postgres != null) + { + postgres.stop(); + } + } + + + + /******************************************************************************* + ** Clear the context. + *******************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** Declared rows reach the frontend metadata per session; the screen shows a + ** widget over local data; the app is closed to anyone without its permission; + ** invalid rows are skipped rather than taking the others down. + *******************************************************************************/ + @Test + void testDeclaredAppRendersAndIsPermissioned() throws Exception + { + QInstance instance = instance(RUNTIME, "runtime-synthetic"); + QContext.init(instance, new QSystemUserSession()); + seed(); + + ///////////////////////////////////////////////////////////////////////////////// + // the customizers created one permission per app and container (URP rows) // + ///////////////////////////////////////////////////////////////////////////////// + assertThat(new CountAction().execute(new CountInput("permission")).getCount()).isGreaterThanOrEqualTo(2); + + var all = new CustomAppsDynamicMetaDataProducer().getCustomAppList(instance, name -> true); + assertThat(all.appMap()).containsKey("qcaOperationsBoard"); + assertThat(all.appMap()).doesNotContainKeys("qcaMissingWidget", "qcaGeneratedName", "qcaNoWidget"); + assertThat(all.appMap().get("qcaOperationsBoard").getWidgets()).containsExactly(WIDGET); + assertThat(all.appMap().get("qcaOperationsBoard").getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION); + + ///////////////////////////////////////////////// + // a user without the app permission sees none // + ///////////////////////////////////////////////// + QContext.init(instance, new QSession().withPermissions(Set.of("unrelated.read"))); + var nothing = new MetaDataAction().execute(new MetaDataInput()); + assertThat(nothing.getApps()).doesNotContainKeys("qcaOperationsBoard", "qcacOperations"); + assertThat(nothing.getAppTree().stream().map(n -> n.getName())).doesNotContain("qcacOperations"); + assertThatThrownBy(() -> new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WIDGET)))) + .isInstanceOf(QPermissionDeniedException.class); + + ///////////////////////////////////////////////////////////////////////////////// + // a user granted the app (and its container) sees it and renders real DB data // + ///////////////////////////////////////////////////////////////////////////////// + QContext.init(instance, new QSession().withPermissions(Set.of("qcaOperationsBoard.hasAccess", "qcacOperations.hasAccess", ITEMS + ".read"))); + var visible = new MetaDataAction().execute(new MetaDataInput()); + assertThat(visible.getApps()).containsKeys("qcaOperationsBoard", "qcacOperations"); + assertThat(visible.getWidgets()).containsKey(WIDGET); + assertThat(visible.getAppTree().stream().map(n -> n.getName())).contains("qcacOperations"); + RenderWidgetOutput rendered = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WIDGET))); + assertThat(((StatisticsData) rendered.getWidgetData()).getCount()).isEqualTo(3); + + //////////////////////////////////////////////////////////////////////////// + // a user granted the app but not the container does not see it in the tree // + //////////////////////////////////////////////////////////////////////////// + QContext.init(instance, new QSession().withPermissions(Set.of("qcaOperationsBoard.hasAccess"))); + assertThat(new MetaDataAction().execute(new MetaDataInput()).getAppTree().stream().map(n -> n.getName())).doesNotContain("qcacOperations"); + } + + + + /******************************************************************************* + ** The runtime role does DML only. + *******************************************************************************/ + @Test + void testRuntimeRoleHasNoDdl() throws Exception + { + try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "customapps"); Statement statement = runtime.createStatement()) + { + assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied"); + assertThatThrownBy(() -> statement.execute("DROP TABLE \"" + CustomApp.TABLE_NAME + "\"")).isInstanceOf(SQLException.class); + } + } + + + + /******************************************************************************* + ** Component bundles are code the browser runs: only paths on this application, + ** or https hosts the configuration lists, are accepted. + *******************************************************************************/ + @Test + void testComponentSourceIsRestricted() throws Exception + { + QInstance instance = instance(RUNTIME, "runtime-synthetic"); + QContext.init(instance, new QSystemUserSession()); + CustomAppsDynamicMetaDataProducer producer = new CustomAppsDynamicMetaDataProducer(); + assertThat(producer.isAllowedComponentSource("/dynamic-qfmd-components/q-embed-looker-dashboards.bundle.js")).isTrue(); + assertThat(producer.isAllowedComponentSource("//evil.example/x.js")).isFalse(); + assertThat(producer.isAllowedComponentSource("https://evil.example/x.js")).isFalse(); + assertThat(producer.isAllowedComponentSource("javascript:alert(1)")).isFalse(); + assertThat(producer.isAllowedComponentSource("data:text/javascript,alert(1)")).isFalse(); + assertThat(producer.isAllowedComponentSource("/ok/../../etc/passwd")).isFalse(); + assertThat(producer.isAllowedComponentSource("/a b.js")).isFalse(); + assertThat(producer.isAllowedComponentSource(null)).isFalse(); + + QInstance allowing = instance(RUNTIME, "runtime-synthetic", List.of("Cdn.Example.com")); + QContext.init(allowing, new QSystemUserSession()); + assertThat(producer.isAllowedComponentSource("https://cdn.example.com/x.js")).isTrue(); + assertThat(producer.isAllowedComponentSource("https://cdn.example.com@evil.example/x.js")).isFalse(); + assertThat(producer.isAllowedComponentSource("http://cdn.example.com/x.js")).isFalse(); + } + + + + /******************************************************************************* + ** The configuration requires its backend. + *******************************************************************************/ + @Test + void testConfigValidation() throws Exception + { + List errors = new java.util.ArrayList<>(); + new CustomAppsQBitConfig().validate(new QInstance(), errors); + assertThat(errors).anyMatch(e -> e.contains("backendName")); + } + + + + /******************************************************************************* + ** Synthetic definitions: one valid local-widget app and three invalid ones. + *******************************************************************************/ + private static void seed() throws QException + { + insert("customAppIcon", new QRecord().withValue("id", 1).withValue("name", "Dashboard").withValue("iconId", "dashboard")); + insert("customAppBackendConfig", new QRecord().withValue("id", 1).withValue("name", "Local widget").withValue("customAppBackendId", CustomAppBackendEnum.LOCAL_WIDGET.getId())); + insert("customAppContainer", new QRecord().withValue("id", 1).withValue("name", "Operations").withValue("customAppIconId", 1).withValue("sequenceNo", 1)); + insert("customAppSection", new QRecord().withValue("id", 1).withValue("name", "Boards").withValue("customAppContainerId", 1).withValue("sequenceNo", 1)); + insertApp(1, "Operations Board", WIDGET); + insertApp(2, "Missing Widget", "noSuchWidget"); + insertApp(3, "Generated Name", "qcaOperationsBoardWidget"); + insertApp(4, "No Widget", null); + for(int i = 1; i <= 3; i++) + { + insert(ITEMS, new QRecord().withValue("name", "Item " + i)); + } + } + + + + /******************************************************************************* + ** One app definition row. + *******************************************************************************/ + private static void insertApp(int id, String name, String widgetName) throws QException + { + insert("customApp", new QRecord().withValue("id", id).withValue("name", name).withValue("customAppBackendConfigId", 1).withValue("customAppSectionId", 1) + .withValue("customAppIconId", 1).withValue("sequenceNo", id).withValue("widgetName", widgetName)); + } + + + + /******************************************************************************* + ** Insert a record. + *******************************************************************************/ + private static void insert(String table, QRecord record) throws QException + { + List records = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords(); + assertThat(records.get(0).getErrors()).isEmpty(); + } + + + + /******************************************************************************* + ** An application on the given role. + *******************************************************************************/ + private static QInstance instance(String user, String password) throws QException + { + return instance(user, password, List.of()); + } + + + + /******************************************************************************* + ** An application: native PG backend as a role, the roles qbit (whose permission + ** table the apps use), this qbit with its metadata customizer, a local table and + ** a permissioned widget over it. + *******************************************************************************/ + private static QInstance instance(String user, String password, List allowedHosts) throws QException + { + QInstance instance = new QInstance(); + instance.setEnforcePermissionsInCoreActions(true); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData(); + backend.setName(BACKEND); + backend.setHostName(host); + backend.setPort(port); + backend.setDatabaseName("customapps"); + backend.setUsername(user); + backend.setPassword(password); + instance.addBackend(backend); + + new UserRolePermissionsQBitProducer().withUserRolePermissionsQBitConfig(new UserRolePermissionsQBitConfig() + .withUserTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed(BACKEND)) + .withTableMetaDataCustomizer((i, table) -> + { + table.setBackendName(BACKEND); + return (table); + })).produce(instance); + new CustomAppsQBitProducer().withQBitConfig(new CustomAppsQBitConfig().withBackendName(BACKEND).withAllowedComponentHosts(allowedHosts)).produce(instance).addSelfToInstance(instance); + instance.setMetaDataActionCustomizer(new QCodeReference(CustomAppsMetaDataActionCustomizer.class)); + + instance.addTable(new QTableMetaData().withName(ITEMS).withBackendName(BACKEND).withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS).withPermissionBaseName(ITEMS)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("name", QFieldType.STRING).withMaxLength(100))); + instance.addWidget(new QWidgetMetaData().withName(WIDGET).withType(WidgetType.STATISTICS.getType()) + .withCodeReference(new QCodeReference(ItemCountWidgetRenderer.class)) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("qcaOperationsBoard"))); + new QInstanceValidator().validate(instance); + return instance; + } + + + + /******************************************************************************* + ** A JDBC connection as a named role. + *******************************************************************************/ + private static Connection connect(String user, String password, String database) throws SQLException + { + return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password); + } + + + + /******************************************************************************* + ** A widget that counts the rows of the local table, as the viewing user. + *******************************************************************************/ + public static class ItemCountWidgetRenderer extends AbstractWidgetRenderer + { + /*************************************************************************** + ** Render the count. + ***************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + Integer count = new CountAction().execute(new CountInput(ITEMS)).getCount(); + return (new RenderWidgetOutput(new StatisticsData().withCount(count))); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java b/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java new file mode 100644 index 0000000..6ce415c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/customapps/TestDdl.java @@ -0,0 +1,87 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.customapps; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; + + +/******************************************************************************* + ** PostgreSQL DDL derived from QQQ table metadata (exact, quoted names, as QQQ + ** quotes them), so a test schema is exactly what the producer defined. + *******************************************************************************/ +final class TestDdl +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private TestDdl() + { + } + + + + /******************************************************************************* + ** One CREATE TABLE. + *******************************************************************************/ + static String create(QTableMetaData table) + { + List lines = new ArrayList<>(); + for(QFieldMetaData field : table.getFields().values()) + { + lines.add(" \"" + field.getName() + "\" " + type(table, field)); + } + for(UniqueKey key : table.getUniqueKeys() == null ? List.of() : table.getUniqueKeys()) + { + lines.add(" UNIQUE (" + String.join(", ", key.getFieldNames().stream().map(n -> "\"" + n + "\"").toList()) + ")"); + } + return "CREATE TABLE \"" + table.getName() + "\" (\n" + String.join(",\n", lines) + "\n)"; + } + + + + /******************************************************************************* + ** Column type and constraints. + *******************************************************************************/ + private static String type(QTableMetaData table, QFieldMetaData field) + { + if(field.getName().equals(table.getPrimaryKeyField())) + { + return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"; + } + String base = switch(field.getType()) + { + case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")"; + case TEXT, HTML -> "TEXT"; + case INTEGER -> "INTEGER"; + case LONG -> "BIGINT"; + case BOOLEAN -> "BOOLEAN"; + case DATE_TIME -> "TIMESTAMP WITH TIME ZONE"; + default -> throw new IllegalStateException("Unsupported type " + field.getType()); + }; + return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""); + } +} -- 2.54.0 From a389bfb8b0ec29a7b55ebacc1baa32aed94f6c51 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 17:20:10 -0600 Subject: [PATCH 2/2] build: pin QQQ 4.1.0-RC.22 from the Gitea registry and update README status Co-Authored-By: Claude Sonnet 5.5 --- README.md | 10 +++++----- pom.xml | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 825799c..903f2ca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -> **Status:** ⚠️ **Experimental / scaffold.** This qbit declares dependencies against `0.9999.0` sentinel versions of `qqq-backend-core` and `qqq-frontend-material-dashboard`, which means it does **not** consume a released framework version. Treat as exploratory until a maintainer confirms its intended state. +> **Status:** 0.1 release line, built on QQQ 4.1 (`4.1.0-RC.22`) and `qbit-user-role-permissions` `0.33.0-RC.1`. Published to the QRun-IO Gitea Maven registry only (`https://git.kof22.com/api/packages/QRun-IO/maven`); release candidates are `0.1.0-RC.N`. QBit that aims to let qqq apps register **custom application screens** declaratively — pages built from widgets, forms, and table views — without writing dedicated front-end components. @@ -27,15 +27,15 @@ src/main/java/com/kingsrook/qbits/customapps/ ## How it relates to other qqq repos -- **Declared dependencies:** `qqq-backend-core`, `qqq-backend-module-rdbms`, `qqq-frontend-material-dashboard`, `qqq-middleware-api`, `qqq-middleware-javalin` — all currently pinned to the `0.9999.0` sentinel version (development build). +- **Declared dependencies:** `qqq-backend-core`, `qqq-middleware-api` (optional), `qqq-middleware-javalin`, `qbit-user-role-permissions`; test scope: `qqq-backend-module-rdbms`, `qqq-backend-module-postgres`. All versions come from the `qqq-bom-pom` import (`4.1.0-RC.22`). - **Inherits from:** `qbit-build-parent` (in the `qbit-bom` repo). - **Frontend pair:** Custom apps render via the standard qqq dashboard; no separate frontend component needed. ## Known gaps -- No automated tests at the time this README was added. -- Sentinel `0.9999.0` versions indicate this qbit is not currently consuming any released qqq build — meaning it can't be cleanly installed by a downstream app. -- This README was added by an audit pass; original maintainer should verify intent and either pin to a real qqq version or mark the repo archived. +- Integration tests (`CustomAppsPostgresTest`) use a PostgreSQL Testcontainer, so building needs Docker. +- Pre-1.0: the metadata and permission model may still change between release candidates. +- Depends on QQQ 4.1 release candidates, so it is not yet pinned to a final QQQ 4.1.0. ## License diff --git a/pom.xml b/pom.xml index eda0d5f..fb12709 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ true - + 0.33.0-RC.1 @@ -102,8 +102,8 @@ com.kingsrook.qqq qqq-bom-pom - 4.1.0-RC.21 + (QRun-IO Gitea Maven registry), the one BOM version shared by the four app-QBit patches. --> + 4.1.0-RC.22 pom import -- 2.54.0