From 2b95715c35d78da63fcea7d5c25e3d0ecd7aaf00 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 b732123636c7980a25824ac38c812bb849412667 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 5 Nov 2026 10:23:32 -0600 Subject: [PATCH 2/2] build: fall back to canonical Maven Central for releases the CI mirror lacks Native verify 5037 failed solely because qbit-user-role-permissions 0.33.0-RC.1 (published to Maven Central; HEAD repo.maven.apache.org 200) was not yet in the CI's Google-hosted Central mirror (the same path returns 404 and the mirror's metadata omits the version). The CI settings mirror only repository id "central" and Maven has no mirror failover. Declare the canonical Central URL as a second, release-only repository with the id the platform's own fallback profile uses (canonical-maven-central). It is tried only when central misses; it adds no credential and no private or snapshot repository, and the dependency version and every other pin are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pom.xml b/pom.xml index eda0d5f..ff03c7c 100644 --- a/pom.xml +++ b/pom.xml @@ -96,6 +96,22 @@ + + + + canonical-maven-central + https://repo.maven.apache.org/maven2/ + + true + + + false + + + + -- 2.54.0