/* * 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/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kingsrook.qbits.scripting; import java.util.List; import java.util.function.Consumer; import com.kingsrook.qbits.scripting.service.IsolatedSourceGuard; import com.kingsrook.qbits.scripting.service.ScriptingService; import com.kingsrook.qbits.scripting.service.WarmRuntimeService; import com.kingsrook.qbits.scripting.worker.WarmWorkerMaintenance; import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.scripts.ScriptTableCustomizer; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData; import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* ** Registers the scripting-owned tables (builds, artifacts, publications, ** bindings, execution history) and creates the instance's ScriptingService. The ** project and draft-source tables are the existing core script tables and must ** already be defined. Every scripting table is read-protected by scripting.view ** and writable only by the service itself (see ScriptingTablePermission). *******************************************************************************/ public class ScriptingQBitProducer implements QBitProducer { private ScriptingQBitConfig config; private Consumer tableEnricher = table -> { // default: nothing; RDBMS hosts add their backend details here }; /*************************************************************************** ** Supply the host configuration. ***************************************************************************/ public ScriptingQBitProducer withConfig(ScriptingQBitConfig value) { config = value; return (this); } /*************************************************************************** ** Let the host add backend-specific table details, for example RDBMS table ** names, to each scripting table. ***************************************************************************/ public ScriptingQBitProducer withTableEnricher(Consumer value) { tableEnricher = value; return (this); } /*************************************************************************** ** Validate prerequisites, add the tables, and install the service. ***************************************************************************/ @Override public void produce(QInstance instance, String namespace) throws QException { if(config == null) { throw new QException("Scripting requires a configuration"); } if(namespace != null && !namespace.isBlank() && !"scripting".equals(namespace)) { throw new QException("The scripting QBit supports the scripting namespace only"); } config.validate(instance); if(!instance.getTable("script").getFields().containsKey("executionMode")) { throw new QException("Scripting requires the reviewed core isolation marker metadata"); } requireSystemReadableProjectLocks(instance); for(TableCustomizers hook : List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD, TableCustomizers.PRE_DELETE_RECORD)) { appendGuard(instance.getTable("script"), hook, new QCodeReference(ScriptTableCustomizer.class)); appendGuard(instance.getTable("scriptRevision"), hook, new QCodeReference(IsolatedSourceGuard.class)); appendGuard(instance.getTable("scriptRevisionFile"), hook, new QCodeReference(IsolatedSourceGuard.class)); } for(String name : List.of("scriptRevision", "scriptRevisionFile")) { QTableMetaData source = instance.getTable(name); QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(source, instance).clone(); source.setPermissionRules(rules.withCustomPermissionChecker(SourceAggregatePermission.wrap(rules.getCustomPermissionChecker()))); source.withoutCapabilities(Capability.TABLE_COUNT, Capability.QUERY_STATS); } appendGuard(instance.getTable("scriptRevision"), TableCustomizers.POST_QUERY_RECORD, new QCodeReference(IsolatedSourceGuard.class)); appendGuard(instance.getTable("scriptRevisionFile"), TableCustomizers.POST_QUERY_RECORD, new QCodeReference(IsolatedSourceGuard.class)); instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-scripting").withVersion(version()).withNamespace("scripting").withConfig(config)); instance.addTable(table(ScriptingTables.BUILD, "Script builds", integer("script_id"), integer("script_revision_id"), string("source_sha256"), text("manifest_json"), string("manifest_attestation"), string("toolchain_id"), string("status"), text("diagnostics_json"), string("requested_by"), dateTime("created_at"))); instance.addTable(table(ScriptingTables.ARTIFACT, "Script artifacts", integer("build_id"), integer("script_id"), integer("version"), string("source_sha256"), string("toolchain_id"), string("entry_point"), string("coordinator_id"), text("bundle"), string("bundle_sha256"), integer("size_bytes"), text("source_map"), string("attestation"), dateTime("created_at")) .withUniqueKey(new UniqueKey("build_id")).withUniqueKey(new UniqueKey("script_id", "version"))); instance.addTable(table(ScriptingTables.PUBLICATION, "Script publications", integer("script_id"), integer("version"), integer("build_id"), integer("artifact_id"), string("published_by"), dateTime("published_at"), string("note")) .withUniqueKey(new UniqueKey("script_id", "version"))); instance.addTable(table(ScriptingTables.BINDING, "Script hook bindings", string("name"), string("table_name"), string("event"), integer("publication_id"), text("grants_json"), text("patchable_fields"), integer("enabled"), string("active_slot"), string("created_by"), dateTime("created_at")) .withUniqueKey(new UniqueKey("active_slot"))); instance.addTable(table(ScriptingTables.EXECUTION, "Script executions", string("operation_id").withMaxLength(64), string("kind"), integer("build_id"), integer("binding_id"), integer("publication_id"), integer("artifact_id"), string("actor_id"), string("event"), string("status"), integer("duration_ms"), string("message"), string("result_action"), dateTime("created_at"))); instance.addTable(table(ScriptingTables.IMPORT, "Script source imports", integer("script_id"), integer("script_revision_id"), string("repository"), string("commit_sha"), string("subdirectory"), string("source_sha256"), string("imported_by"), dateTime("created_at")) .withUniqueKey(new UniqueKey("script_revision_id"))); ScriptingAuthoringProcesses.register(instance); ScriptingNavigation.register(instance); config.setService(new ScriptingService(config.getRuntime())); if(config.getRuntime().getWorker() instanceof WarmWorkerMaintenance) { instance.withRuntimeService(new QCodeReference(WarmRuntimeService.class)); HealthChecksMetaData.ofOrWithNew(instance).withCheck(new HealthCheckMetaData().withName("scripting.warm") .withLabel("Scripting warm code").withComponent("scripting").withIncludeInReadiness(true) .withFailureThreshold(1).withIntervalSeconds(10).withTimeoutMs(5000).withProbeCache(false) .withCheck(new QCodeReference(WarmRuntimeService.class))); } } /*************************************************************************** ** The QBit alone reads and writes its project tables as the QQQ system session, ** after the caller has been authorized. A host may lock those tables with ** native record security (for example by tenant); QQQ lets the system session ** through only for a key type that defines an all-access key. Without it every ** internal read would silently see no rows and even the owner's authoring would ** fail as "not found", so refuse the configuration with the exact remedy. ***************************************************************************/ private static void requireSystemReadableProjectLocks(QInstance instance) throws QException { for(String tableName : List.of("script", "scriptRevision", "scriptRevisionFile")) { for(var lock : flatten(instance.getTable(tableName).getRecordSecurityLocks())) { var keyType = lock.getSecurityKeyType() == null ? null : instance.getSecurityKeyType(lock.getSecurityKeyType()); if(keyType != null && (keyType.getAllAccessKeyName() == null || keyType.getAllAccessKeyName().isBlank())) { throw new QException("Scripting reads its project tables as the QQQ system session, but the record-security lock on table " + tableName + " uses key type " + keyType.getName() + " which defines no all-access key. Configure QSecurityKeyType.withAllAccessKeyName for it."); } } } } /*************************************************************************** ** Leaf locks of a possibly nested lock list. ***************************************************************************/ private static List flatten( List locks) { List leaves = new java.util.ArrayList<>(); for(var lock : locks == null ? List.of() : locks) { if(lock instanceof com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock multi) { leaves.addAll(flatten(multi.getLocks())); } else { leaves.add(lock); } } return (leaves); } /*************************************************************************** ** Keep host behavior and apply integrity checks to its final record values. ***************************************************************************/ private static void appendGuard(QTableMetaData table, TableCustomizers hook, QCodeReference guard) { QCodeReference prior = table.getCustomizer(hook.getRole()).orElse(null); if(prior == null) { table.withCustomizer(hook, guard); } else { table.getCustomizers().put(hook.getRole(), MultiCustomizer.of(prior, guard)); } } /*************************************************************************** ** The build-produced JAR version; IDE/test class directories say so honestly. ***************************************************************************/ private static String version() { String version = ScriptingQBitProducer.class.getPackage().getImplementationVersion(); return (version == null || version.isBlank() ? "development" : version); } /*************************************************************************** ** One scripting table: integer id, the given fields, all non-editable in the ** UI, protected by the shared table permission rule. ***************************************************************************/ private QTableMetaData table(String name, String label, QFieldMetaData... fields) { QTableMetaData table = new QTableMetaData() .withName(name) .withLabel(label) .withBackendName(config.getBackendName()) .withPrimaryKeyField("id") .withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(ScriptingTablePermission.class)) .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(ScriptingTablePermission.class))) .withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id").withIsEditable(false)); for(QFieldMetaData field : fields) { table.withField(field); } tableEnricher.accept(table); return (table); } /*************************************************************************** ** Read-only string field with a readable label. ***************************************************************************/ private static QFieldMetaData string(String name) { return (field(name, QFieldType.STRING)); } /*************************************************************************** ** Read-only long text field. ***************************************************************************/ private static QFieldMetaData text(String name) { return (field(name, QFieldType.TEXT)); } /*************************************************************************** ** Read-only integer field. ***************************************************************************/ private static QFieldMetaData integer(String name) { return (field(name, QFieldType.INTEGER)); } /*************************************************************************** ** Read-only timestamp field. ***************************************************************************/ private static QFieldMetaData dateTime(String name) { return (field(name, QFieldType.DATE_TIME)); } /*************************************************************************** ** Build a field whose label is derived from its snake_case name. ***************************************************************************/ private static QFieldMetaData field(String name, QFieldType type) { String label = name.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + name.substring(1).replace('_', ' '); return (new QFieldMetaData(name, type).withLabel(label).withIsEditable(false)); } }