diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa7424..3d354ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased - X1/Y1 + +- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in. + +- Register explicit assistant-channel denials on every assistant table, process, + report and widget, preserving core security patterns; fail runtime startup + when an assistant object is exposed. +- Bump the assistant UI API extension to 1.1.0 for optional owned-turn attribution + on explain-access, and regenerate its OpenAPI contract. +- Preserve turn attribution in knowledge discovery, RAG reads and explain-access + calls; confirm actions retain the original turn and scheduled runs retain the task. +- Build audit links with the presentation filter-link builder, filtering by + record, table and channel, including deletes; widen audit link storage to TEXT. + Document the host audit migration and the QBit column widening. +- Verify the admin denial matrix through the real invoker and Next UI, startup + failures, and confirmed-write human/channel attribution with decoded audit links. ## Unreleased - Email ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 4da48f2..5dfa0e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def - Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit). - Every route uses `ExecutorSessionUtils.setupSession` (the user's own session). - `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders. +- **Channel wall:** every `assistant_*` object (table, process, report and widget) is denied to the `assistant` channel; startup fails otherwise. Requests carry `assistant/` or `assistant/scheduled/`; administration uses `next-ui`. - **Data access:** - The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path. - Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit). diff --git a/README.md b/README.md index 5c66ed1..94ec3a4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,37 @@ Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`. Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data. +## Host audit attribution (X1/Y1) + +Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant +configuration table, process, report and widget to the `assistant` channel; +runtime initialization fails if an assistant object is exposed. The same human +can still administer it through Next UI (`next-ui`). Core's security-surface +deny patterns remain in place. + +Enable the audit client field in the host's core audit provider **after adding +the backing column**: + +```java +new AuditsMetaDataProvider().withClientChannelField(true) + .defineAll(qInstance, backendName, null); +``` + +Add nullable `audit.client_channel VARCHAR(100)` using the host's migration and +column-naming conventions. For kof22 this requires a feature-named changeset +under `changesets/features/`; this QBit does not migrate the host's audit table. +The actor remains the signed-in human; `clientChannel` distinguishes +`assistant/`, `assistant/scheduled/` and `next-ui`. + +Apply the X1/Y1 DDL in [UPGRADE.md](UPGRADE.md) to widen the existing +`assistant_tool_call.audit_href` column to TEXT for encoded filters. +Successful record writes store `assistant_tool_call.auditHref` as an `/app/audit` +filter link over `recordId`, `auditTable.name` and `clientChannel`. The human +opens this link with their UI permissions; the assistant cannot read audits by +default. Without the audit channel field, the QBit leaves `auditHref` empty. +Confirmed scheduled actions use their original turn id when the human approves. + + ## RAG setup Knowledge retrieval is optional. The vector index stores **no record content**: @@ -649,10 +680,19 @@ channel-denied, including their effective permission rules. A host that intends to expose record history can opt in during instance configuration: ```java -ClientChannelsMetaData.ofOrWithNew(qInstance) - .getDeniedPatterns().get("assistant").remove("audit*"); +new AssistantQBitConfig() + .withRecordHistory(true); ``` -This removes only the default audit pattern; other channel denials and the -signed-in user's audit permissions still apply. Keep explicit denials for audit -surfaces the host does not intend to expose. The default remains unchanged. +This is an owner decision and a hard wall: record history defaults to disabled; +**kof22 does not opt in**. On QQQ RC.6, the opt-in removes exactly `audit*` +from the assistant's denied patterns (the former manual configuration step). +RC.6 does not add a separate channel write denial for audits. On later RCs that +include QRun-IO/qqq#1193, it calls `withAssistantAuditReads()`, which allows audit +reads but denies audit writes. The user's own audit permissions still apply. +Other patterns, including `worm_audit_*`, `assistant_*`, and security surfaces, +remain untouched, as do explicit object and permission-rule denials. + +History visibility uses core's `isDenied` read/all-operation check. RC.6 has no +operation argument; the later write-only deny patterns must remain separate +from that check so they do not hide the read tool. diff --git a/UPGRADE.md b/UPGRADE.md index 130005f..f02bced 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,24 @@ # Upgrading qbit-assistant +## X1/Y1: channel wall and audit attribution + +Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is +explicitly denied to channel `assistant`; startup rejects an exposed object. +Host customizations must preserve these denials. Administration uses `next-ui`. + +Before enabling `AuditsMetaDataProvider.withClientChannelField(true)`, add the +nullable `audit.client_channel VARCHAR(100)` column using the host migration +conventions. kof22 needs a feature changeset under `changesets/features/` (for +example `2026-10-14-audit-client-channel.sql`); no host migration is applied here. +Existing audit rows remain null. The test fixture enables the field. + +The existing `assistant_tool_call.auditHref` column now points to audit rows +filtered by record, table and client channel, including for deletes. It is empty +when the host has not enabled the audit channel field. Apply the X1/Y1 DDL in +`docs/schema-postgres.sql` to widen the existing `assistant_tool_call.audit_href` +column to TEXT: encoded filters with long table names or string record ids can +exceed the old 500-character limit. No new QBit column is required. `GET /assistant/access` accepts an optional owned `turnId` for attribution. + ## Provider API extraction (#30) Hosts that depend on `qbit-assistant` keep working: it now brings in @@ -42,8 +61,8 @@ See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning. QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique schedule-slot insertion as its optimistic claim, then advances `nextRunAt` before starting work. Crashes can skip a slot; they never retry it. No ArchUnit -exception or fabricated session is introduced. Core client-channel enforcement -arrives with the client-channel RC; the header and tool audit are already sent. +exception or fabricated session is introduced. Core RC.6 enforces client-channel denials; scheduled requests carry +`assistant/scheduled/`. Notifications use the optional Alerts QBit notification SPI with a single-user audience. Without it, delivery logs run id/status only. All output modes retain diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d28383b..2d93e41 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -287,6 +287,14 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad - **Tests:** an assistant write produces audit rows with the human actor and `via=assistant/`; the same write from the UI produces `via=next-ui`. - kof22 needs a feature-named changeset (`changesets/features/...`) for the audit column. +### X1/Y1 as built +- Record history is an explicit, default-off host opt-in (`withRecordHistory(true)`); kof22 does not opt in. RC.6 removes only `audit*`; later core #1193 support allows audit reads with a write-only denial. Other walls and user audit permissions remain intact. +- RC.6's invoker fixes the `assistant` channel server-side and ignores spoofed client headers. Turn context survives knowledge discovery, RAG reads, confirm actions and owned-turn explain-access; scheduled calls retain `scheduled/`. +- The producer enumerates assistant tables, processes, reports and widgets (including source-QBit metadata), adds explicit denied-client rules without replacing user permissions, and registers `assistant_*` idempotently. Runtime construction independently validates the wall with core's `isDenied` and fails on exposed objects. +- The fixture enables core's audit channel field. Hosts must migrate the nullable audit column before enabling it; kof22 needs its own feature changeset. +- Successful insert/update/delete tool calls link to `/app/audit` with `recordId`, `auditTable.name` and `clientChannel` criteria, using the same encoder as `present_results`. Hosts without the channel field get no audit link. Existing audit link storage widens to TEXT for long encoded filters. +- HTTP tests enumerate the configuration denial matrix for an admin, contrast Next UI access, reject broken startup metadata, and verify confirmed-write actor/channel plus the decoded audit filter. + ### P. Providers, local models, HIPAA routing, budgets 1. **Providers and models as data, any number, each with an enabled toggle.** - Kinds: diff --git a/docs/schema-postgres.sql b/docs/schema-postgres.sql index 72a273e..e9e618f 100644 --- a/docs/schema-postgres.sql +++ b/docs/schema-postgres.sql @@ -103,7 +103,7 @@ CREATE TABLE assistant_tool_call redactions_json TEXT, ui_api_operation VARCHAR(255), http_status INTEGER, - audit_href VARCHAR(500), + audit_href TEXT, pending_action_id INTEGER, create_date TIMESTAMP WITH TIME ZONE ); @@ -574,3 +574,6 @@ CREATE TABLE assistant_scheduled_run ( UNIQUE (task_id, scheduled_for) ); CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at); + +-- X1/Y1: encoded audit filters can exceed the old 500-character record links. +ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java index d2b5961..21877c0 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java @@ -90,6 +90,7 @@ public class AssistantQBitConfig implements QBitConfig private String conversationEncryptionKeyEnvName; private Boolean enabled = true; + private Boolean recordHistory = false; private String backendName; private String providerName = DEFAULT_PROVIDER_NAME; private AssistantModelRouting models = new AssistantModelRouting(); @@ -1758,6 +1759,28 @@ public class AssistantQBitConfig implements QBitConfig + /******************************************************************************* + ** Whether the host opts into record history; false by default. + *******************************************************************************/ + public Boolean getRecordHistory() + { + return (recordHistory); + } + + + + /******************************************************************************* + ** Owner decision — hard wall: record history requires explicit host opt-in. + ** kof22 does not opt in. The user's own audit permissions still apply. + *******************************************************************************/ + public AssistantQBitConfig withRecordHistory(boolean recordHistory) + { + this.recordHistory = recordHistory; + return (this); + } + + + /******************************************************************************* ** Getter for hipaaMode *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java index 97d1481..101b4e9 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitProducer.java @@ -25,6 +25,7 @@ import com.kingsrook.qbits.assistant.api.AssistantRoutes; import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels; import com.kingsrook.qbits.assistant.home.AssistantAccessWidget; import com.kingsrook.qbits.assistant.home.AssistantHome; +import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall; import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData; import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder; import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens; @@ -41,6 +42,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; @@ -201,12 +203,42 @@ public class AssistantQBitProducer implements QBitMetaDataProducer denied = channels.getDeniedPatterns().get("assistant"); + if(denied != null) + { + denied.removeIf("audit*"::equals); + } + } + catch(ReflectiveOperationException e) + { + throw (new IllegalStateException("Could not enable assistant audit reads.", e)); + } + } + + + /******************************************************************************* ** Declare budget alerts as top-level core metadata for admin visibility. *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java index 97c24a2..d28fe05 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java @@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.engine.ProfileService; import com.kingsrook.qbits.assistant.engine.ProviderService; import com.kingsrook.qbits.assistant.engine.RateLimiter; import com.kingsrook.qbits.assistant.engine.RunningTurn; +import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore; import com.kingsrook.qbits.assistant.rag.RagService; @@ -103,6 +104,7 @@ public class AssistantRuntime *******************************************************************************/ public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock) { + AssistantChannelWall.validate(qInstance); this.qInstance = qInstance; this.config = config; this.clock = clock; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java index 5ccbd62..d82ecff 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java @@ -103,7 +103,17 @@ public class AccessExplainer *******************************************************************************/ public List explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException { - UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain")); + return (explain(gateway, sessionUUID, credentials, policy, tableName, prompt, null)); + } + + + + /******************************************************************************* + ** Preserve a known turn's attribution on the metadata request. + *******************************************************************************/ + public List explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt, String turnId) throws QException + { + UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain").withClientDetail(turnId)); if(!response.isSuccess()) { throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage()))); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java index f76988f..df9d926 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java @@ -627,6 +627,7 @@ public class AssistantApi store.insert(new AssistantToolCall() .withTurnId(action.getTurnId()) + .withClientChannel("assistant" + (action.getTurnId() == null ? "" : "/" + action.getTurnId())) .withConversationId(action.getConversationId()) .withUserId(userId) .withToolCallId(action.getToolCallId()) @@ -733,10 +734,24 @@ public class AssistantApi ** reason. *******************************************************************************/ public Map access(String table, String prompt) throws QException, AssistantApiException + { + return (access(table, prompt, null)); + } + + + + /******************************************************************************* + ** A supplied turn must belong to this user before using it for attribution. + *******************************************************************************/ + public Map access(String table, String prompt, Integer turnId) throws QException, AssistantApiException { requireAccess(); + if(turnId != null) + { + requireTurn(turnId); + } List> tables = new ArrayList<>(); - for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt)) + for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId))) { if(!access.uiVisible() && access.mode() == DataAccessMode.NONE) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java index 518e982..b4aa2f6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -61,7 +61,7 @@ import io.javalin.http.HttpStatus; public final class AssistantSpecs { public static final String EXTENSION_NAME = "assistant"; - public static final String EXTENSION_VERSION = "1.0.0"; + public static final String EXTENSION_VERSION = "1.1.0"; public static final String PREFIX = "/assistant"; @@ -1312,7 +1312,7 @@ public final class AssistantSpecs @Override public List defineRequestParameters() { - return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."))); + return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."), queryParam("turnId", Type.INTEGER, "Attribute this request to an owned turn."))); } @@ -1329,7 +1329,7 @@ public final class AssistantSpecs protected Object handle(AssistantApi api, JsonInput input) throws Exception { String table = input.getQueryParam("table"); - return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"))); + return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"), input.getQueryParamInteger("turnId"))); } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java index 446ec56..f427e6d 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java @@ -106,6 +106,16 @@ public class ContextBuilder ** kernel's navigate and search_help. *******************************************************************************/ public List tools(PolicyService.ToolPolicy policy) + { + return (tools(policy, null)); + } + + + + /******************************************************************************* + ** Attribute tool-discovery requests to the turn that needs them. + *******************************************************************************/ + private List tools(PolicyService.ToolPolicy policy, String turnId) { Map byName = new TreeMap<>(); for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable())) @@ -123,7 +133,7 @@ public class ContextBuilder .withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict())))); } } - if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession())) + if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession(), turnId)) { byName.put(KnowledgeTool.NAME, knowledgeTool.definition()); } @@ -216,12 +226,23 @@ public class ContextBuilder *******************************************************************************/ public ModelRequest build(QSession session, String model, String effort, List history, String userText, Map pageContext, PolicyService.ToolPolicy toolPolicy, String profileInstructions) + { + return (build(session, model, effort, history, userText, pageContext, toolPolicy, profileInstructions, null)); + } + + + + /******************************************************************************* + ** Include the turn in any UI API calls needed to discover available tools. + *******************************************************************************/ + public ModelRequest build(QSession session, String model, String effort, List history, String userText, Map pageContext, PolicyService.ToolPolicy toolPolicy, + String profileInstructions, String turnId) { ModelRequest request = new ModelRequest() .withModel(model) .withEffort(effort) .withMaxTokens(config.getModels().getMainMaxOutputTokens()) - .withTools(tools(toolPolicy)); + .withTools(tools(toolPolicy, turnId)); request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG)); request.getSystem().add(new SystemBlock(userBlock(session) + (StringUtils.hasContent(profileInstructions) ? "\n" + profileInstructions : ""), CacheHint.SHORT)); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index eca8012..1dc764a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -262,7 +262,7 @@ public class TurnEngine implements Runnable String appName = runtime.appNameFor(pageContext); ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId()); String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName)); - ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText); + ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText, turn.getId() == null ? null : String.valueOf(turn.getId())); Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest(); if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest)) { @@ -885,7 +885,7 @@ public class TurnEngine implements Runnable .withUserId(userId) .withToolCallId(use.getToolUseId()) .withToolName(use.getToolName()) - .withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId()) + .withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId()) .withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS)) .withOutcome(outcome) .withPermissionCheck(result.getPermissionCheck()) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantChannelWall.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantChannelWall.java new file mode 100644 index 0000000..3417052 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantChannelWall.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.metadata; + + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.assistant.AssistantQBitProducer; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware; + + +/******************************************************************************* + ** Register and verify the assistant's configuration wall beneath user permissions. + *******************************************************************************/ +public final class AssistantChannelWall +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AssistantChannelWall() + { + } + + + + /******************************************************************************* + ** Enumerate the instance so new assistant metadata cannot miss registration. + ** Preserve host permissions and other denied channels. + *******************************************************************************/ + public static void register(QInstance qInstance) + { + ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "assistant_*"); + for(MetaDataWithPermissionRules object : objects(qInstance)) + { + QPermissionRules rules = object.getPermissionRules(); + rules = rules == null ? qInstance.getDefaultPermissionRules().clone() : rules.clone(); + Set denied = new HashSet<>(rules.getDeniedClients() == null ? Set.of() : rules.getDeniedClients()); + denied.add("assistant"); + object.setPermissionRules(rules.withDeniedClients(denied)); + } + } + + + + /******************************************************************************* + ** Fail before runtime initialization if a host customization opens the wall. + *******************************************************************************/ + public static void validate(QInstance qInstance) + { + ClientChannelsMetaData channels = ClientChannelsMetaData.of(qInstance); + for(MetaDataWithPermissionRules object : objects(qInstance)) + { + QPermissionRules rules = object.getPermissionRules() == null ? qInstance.getDefaultPermissionRules() : object.getPermissionRules(); + if(!channels.isDenied("assistant", object.getName(), rules)) + { + throw (new IllegalStateException("Assistant channel wall: " + object.getName() + " must be denied to client channel assistant before startup.")); + } + } + } + + + + /******************************************************************************* + ** Tables, processes, reports and widgets, including host-added assistant objects. + *******************************************************************************/ + private static List objects(QInstance qInstance) + { + List objects = new ArrayList<>(); + objects.addAll(qInstance.getTables().values()); + objects.addAll(qInstance.getProcesses().values()); + objects.addAll(qInstance.getReports().values()); + objects.addAll(qInstance.getWidgets().values()); + return (objects.stream().filter(object -> (object.getName() != null && object.getName().startsWith("assistant")) + || (object instanceof SourceQBitAware source && (AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID).equals(source.getSourceQBitName()))).toList()); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantToolCall.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantToolCall.java index 37c3a3c..b032aca 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantToolCall.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/model/AssistantToolCall.java @@ -100,7 +100,7 @@ public class AssistantToolCall extends QRecordEntity @QField(isEditable = false) private Integer httpStatus; - @QField(isEditable = false, maxLength = 500) + @QField(isEditable = false) private String auditHref; @QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java index 5d75cd1..0f256b6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java @@ -145,7 +145,7 @@ public class RagService { PolicyService.ToolPolicy policy = context.policy(); UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(), - new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null); + new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId()); return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk))); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/FilterLinkBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/FilterLinkBuilder.java new file mode 100644 index 0000000..fb63a86 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/FilterLinkBuilder.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.tools; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** Encode server-assembled filters in the format consumed by the Next UI. + *******************************************************************************/ +public final class FilterLinkBuilder +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private FilterLinkBuilder() + { + } + + + + /******************************************************************************* + ** Callers validate model-supplied fields before constructing their criteria. + *******************************************************************************/ + public static String href(String tableName, List> criteria) + { + return (AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(Map.of("criteria", criteria, "booleanOperator", "AND")), StandardCharsets.UTF_8)); + } +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java index b4c5e4f..0989c86 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java @@ -71,6 +71,16 @@ public class KnowledgeTool ** A source must be enabled and readable in the UI before offering the tool. *******************************************************************************/ public Boolean offered(PolicyService.ToolPolicy policy, QSession session) + { + return (offered(policy, session, null)); + } + + + + /******************************************************************************* + ** Tool discovery is a UI API call too: retain the turn when known. + *******************************************************************************/ + public Boolean offered(PolicyService.ToolPolicy policy, QSession session, String turnId) { if(session == null || (policy != null && !policy.allows(NAME))) { @@ -90,7 +100,7 @@ public class KnowledgeTool { return (false); } - return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), policy, null, null).stream() + return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), null, policy, null, null, turnId).stream() .anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead())); } catch(QException e) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java index 12cd62c..59beff6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java @@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.tools; import java.io.InputStream; import java.math.BigDecimal; import java.math.MathContext; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -388,7 +387,7 @@ public class PresentResultsTool values.forEach(PresentResultsTool::scalar); output.add(map("fieldName", fieldName, "operator", operator, "values", values)); } - return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8))); + return (safeHref(FilterLinkBuilder.href(tableName, output))); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java index d90e23a..d2894a1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java @@ -372,6 +372,9 @@ public class UiApiToolbox /******************************************************************************* ** Offer history only when every audit table it queries is available to the ** assistant channel, including inherited permission rules. + ** RC.6 isDenied checks the all-operation wall, with no operation argument. + ** Core #1193 must keep write-only denials separate from this read check; + ** do not consult write-deny patterns here when offering this read tool. *******************************************************************************/ private boolean historyAllowed() { @@ -1285,10 +1288,19 @@ public class UiApiToolbox default -> "Deleted"; }; String label = table == null ? tableName : label(table); + QTableMetaData audit = qInstance.getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT); + if(id != null && audit != null && audit.getField(AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL) != null) + { + String channel = "assistant" + (context.turnId() == null ? "" : "/" + context.turnId()); + outcome.auditHref = FilterLinkBuilder.href(AuditsMetaDataProvider.TABLE_NAME_AUDIT, List.of( + Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of(id)), + Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of(tableName)), + Map.of("fieldName", AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL, "operator", "EQUALS", "values", List.of(channel)))); + } if(id != null && !DELETE_RECORD.equals(toolName)) { String href = AiLinkBuilder.recordHref(tableName, id); - outcome.auditHref = href; + outcome.result.getRecordRefs().add(new AiRecordRef().withTableName(tableName).withTableLabel(label).withId(id).withLabel(label + " " + id).withHref(href)); } outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRowCount(1).withSummary(verb + " " + label + (id == null ? "" : " " + id)) diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index 0a6097a..0b2e636 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -212,7 +212,7 @@ info: description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ \ and data access explanations" title: "QQQ Middleware API extension: assistant" - version: "1.0.0" + version: "1.1.0" openapi: "3.0.3" paths: /qqq/v1/assistant/config: @@ -1038,6 +1038,12 @@ paths: required: false schema: type: "string" + - description: "Attribute this request to an owned turn." + in: "query" + name: "turnId" + required: false + schema: + type: "integer" responses: 400: content: diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java new file mode 100644 index 0000000..8ac3a09 --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantAuditAttributionTest.java @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant; + + +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.assistant.access.DataAccessMode; +import com.kingsrook.qbits.assistant.api.AssistantApi; +import com.kingsrook.qbits.assistant.model.AssistantDataAccess; +import com.kingsrook.qbits.assistant.model.AssistantPendingAction; +import com.kingsrook.qbits.assistant.model.AssistantToolCall; +import com.kingsrook.qbits.assistant.model.AssistantTurn; +import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.UiApiTestServer; +import com.kingsrook.qbits.assistant.tools.FilterLinkBuilder; +import com.kingsrook.qbits.assistant.uiapi.UiApiResult; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Confirmed writes retain the human actor and link only to their channel's audits. + *******************************************************************************/ +class AssistantAuditAttributionTest extends BaseAssistantTest +{ + /******************************************************************************* + ** Insert, update and delete all create usable audit links, including deletions. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = {"create_record", "update_record", "delete_record"}) + void confirmedWriteAttributesActorAndChannel(String tool) throws Exception + { + String base = UiApiTestServer.serve(qInstance, config); + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); + var store = config.getRuntime().getStore(); + store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("page").withMode(DataAccessMode.READ_WRITE.name())); + config.getRuntime().getDataAccessService().invalidate(); + AssistantTurn turn = store.insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN)); + AssistantPendingAction action = store.insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withTurnId(turn.getId()) + .withToolCallId("write-1").withToolName(tool).withTableName("page").withRecordId("12") + .withParamsJson(JsonUtils.toJson(tool.equals("delete_record") ? Map.of() : Map.of("title", "Assistant changed this page"))) + .withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(Instant.now().plusSeconds(600))); + Map result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId()); + assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED); + String channel = "assistant/" + turn.getId(); + List audits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, channel)); + assertThat(audits).hasSize(1); + QRecord audit = audits.getFirst(); + assertHumanActor(audit); + assertThat(records("auditTable", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditTableId"))).getFirst().getValueString("name")).isEqualTo("page"); + List calls = store.query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, action.getId()), AssistantToolCall.class); + assertThat(calls).hasSize(1); + assertThat(calls.getFirst().getClientChannel()).isEqualTo(channel); + String href = calls.getFirst().getAuditHref(); + assertThat(href).startsWith("/app/audit?filter="); + QQueryFilter filter = JsonUtils.toObject(URLDecoder.decode(URI.create(href).getRawQuery().substring("filter=".length()), StandardCharsets.UTF_8), QQueryFilter.class); + assertThat(filter.getCriteria()).hasSize(3); + assertThat(filter.getCriteria()).anySatisfy(criterion -> + { + assertThat(criterion.getFieldName()).isEqualTo("clientChannel"); + assertThat(criterion.getValues()).containsExactly(channel); + }); + assertThat(filter.getCriteria()).anySatisfy(criterion -> + { + assertThat(criterion.getFieldName()).isEqualTo("auditTable.name"); + assertThat(criterion.getValues()).containsExactly("page"); + }); + assertThat(filter.getCriteria()).anySatisfy(criterion -> + { + assertThat(criterion.getFieldName()).isEqualTo("recordId"); + assertThat(criterion.getValues()).extracting(String::valueOf).containsExactly(audit.getValueString("recordId")); + }); + UiApiResult linked = ui(base, "POST", "/table/audit/query", JsonUtils.toJson(Map.of("filter", filter))); + assertThat(linked.status()).isEqualTo(200); + assertThat((List) linked.json().get("records")).hasSize(1); + + String directMethod = tool.equals("create_record") ? "POST" : tool.equals("update_record") ? "PATCH" : "DELETE"; + String directPath = "/table/page" + (tool.equals("create_record") ? "" : "/11"); + UiApiResult direct = ui(base, directMethod, directPath, tool.equals("delete_record") ? null : "{\"title\":\"UI changed this page\"}"); + assertThat(direct.status()).isBetween(200, 299); + List directAudits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, "next-ui")); + assertThat(directAudits).hasSize(1).allSatisfy(this::assertHumanActor); + } + + + + /******************************************************************************* + ** Encoded filters for string keys must survive persistence without truncation. + *******************************************************************************/ + @Test + void storesLongAuditLinks() throws Exception + { + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); + String href = FilterLinkBuilder.href("audit", List.of( + Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of("opaque/record key ".repeat(12))), + Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of("customer_external_document")), + Map.of("fieldName", "clientChannel", "operator", "EQUALS", "values", List.of("assistant/42")))); + assertThat(href.length()).isGreaterThan(500); + assertThat(qInstance.getTable(AssistantToolCall.TABLE_NAME).getField("auditHref").getMaxLength()).isNull(); + AssistantToolCall call = config.getRuntime().getStore().insert(new AssistantToolCall().withUserId(AssistantTestFixture.ADMIN).withAuditHref(href)); + assertThat(config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME, + new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, call.getId()), AssistantToolCall.class).getFirst().getAuditHref()).isEqualTo(href); + } + + + + /******************************************************************************* + ** Explain access carries an owned turn, or the bare channel outside a turn. + *******************************************************************************/ + @Test + void explainAccessUsesOwnedTurnOrBareChannel() throws Exception + { + UiApiTestServer.serve(qInstance, config); + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); + AssistantTurn turn = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN)); + AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession()); + api.access("page", null, turn.getId()); + assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant/" + turn.getId()); + api.access("page", null); + assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant"); + AssistantTurn other = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.EDITOR)); + assertThatThrownBy(() -> api.access("page", null, other.getId())).hasMessageContaining("not found"); + } + + + + /******************************************************************************* + ** Read the fixture's audit evidence independently of the assistant's gateway. + *******************************************************************************/ + private List records(String table, QQueryFilter filter) throws Exception + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()); + } + + + + /******************************************************************************* + ** Core's audit user is the authenticated human's display name. + *******************************************************************************/ + private void assertHumanActor(QRecord audit) throws Exception + { + assertThat(records("auditUser", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditUserId"))).getFirst().getValueString("name")) + .isEqualTo("Name of " + AssistantTestFixture.ADMIN); + } + + + + /******************************************************************************* + ** A direct Next UI request from the same administrator. + *******************************************************************************/ + private UiApiResult ui(String base, String method, String path, String body) throws Exception + { + HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN) + .header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json") + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + return (new UiApiResult(response.statusCode(), response.body())); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java new file mode 100644 index 0000000..e04eb89 --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/AssistantChannelWallTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant; + + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall; +import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.UiApiTestServer; +import com.kingsrook.qbits.assistant.tools.UiApiToolbox; +import com.kingsrook.qbits.assistant.uiapi.UiApiCall; +import com.kingsrook.qbits.assistant.uiapi.UiApiResult; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The real UI API enforces the channel wall even for assistant administrators. + *******************************************************************************/ +class AssistantChannelWallTest extends BaseAssistantTest +{ + /******************************************************************************* + ** Every produced object is explicitly denied, with core security defaults intact. + *******************************************************************************/ + @Test + void registersAllObjectsIdempotently() + { + QTableMetaData renamed = new QTableMetaData().withName("custom_settings"); + renamed.setSourceQBitName(AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID); + qInstance.addTable(renamed); + qInstance.addReport(new QReportMetaData().withName("assistant_future_report").withPermissionRules(new QPermissionRules().withDeniedClients(Set.of("cli")))); + AssistantChannelWall.register(qInstance); + AssistantChannelWall.register(qInstance); + List objects = objects(); + assertThat(objects).isNotEmpty().allSatisfy(object -> assertThat(object.getPermissionRules().getDeniedClients()).contains("assistant")); + assertThat(qInstance.getReport("assistant_future_report").getPermissionRules().getDeniedClients()).contains("cli"); + assertThat(ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant")) + .contains("assistant_*", "authentik_*", "audit*", "*api_key*", "*credential*", "*secret*") + .doesNotHaveDuplicates(); + assertThat(renamed.getPermissionRules().getDeniedClients()).contains("assistant"); + AssistantChannelWall.validate(qInstance); + } + + + + /******************************************************************************* + ** Removing both layers from one object fails runtime startup, for every kind. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = {"table", "process", "report", "widget"}) + void startupRejectsAnUndeniedObject(String kind) + { + qInstance.addReport(new QReportMetaData().withName("assistant_report")); + AssistantChannelWall.register(qInstance); + MetaDataWithPermissionRules object = switch(kind) + { + case "table" -> qInstance.getTables().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow(); + case "process" -> qInstance.getProcesses().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow(); + case "report" -> qInstance.getReport("assistant_report"); + default -> qInstance.getWidgets().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow(); + }; + ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant").remove("assistant_*"); + object.getPermissionRules().withDeniedClients(Set.of()); + assertThatThrownBy(() -> new AssistantRuntime(qInstance, config)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Assistant channel wall").hasMessageContaining(object.getName()); + } + + + + /******************************************************************************* + ** Direct invocations bypass tool exposure checks, proving the wall lives in core. + *******************************************************************************/ + @Test + void adminDeniedAcrossEveryTableAndProcess() throws Exception + { + String base = UiApiTestServer.serve(qInstance, config); + UiApiResult metadata = invoke(new UiApiCall("GET", "/metaData")); + assertThat(metadata.status()).isEqualTo(200); + UiApiResult uiMetadata = ui(base, "GET", "/metaData", null); + assertThat(uiMetadata.status()).isEqualTo(200); + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config)); + UiApiToolbox.Outcome listed = config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null, + config.getRuntime().getUiApiGateway(), null, null, null, "42"), "list", UiApiToolbox.LIST_TABLES, Map.of()); + for(String table : qInstance.getTables().keySet().stream().filter(name -> name.startsWith("assistant")).toList()) + { + assertThat(((Map) metadata.json().get("tables")).containsKey(table)).isFalse(); + assertThat(((Map) uiMetadata.json().get("tables")).containsKey(table)).isTrue(); + assertThat(listed.getResult().getModelContent()).doesNotContain("\"" + table + "\""); + for(UiApiCall call : List.of( + new UiApiCall("POST", "/table/{tableName}/query").withJsonBody(Map.of()), + new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1"), + new UiApiCall("POST", "/table/{tableName}").withJsonBody(Map.of()), + new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1").withJsonBody(Map.of()), + new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1"))) + { + assertThat(invoke(call.withPathParam("tableName", table)).status()).as("%s %s", table, call.operation()).isEqualTo(403); + } + assertThat(ui(base, "POST", "/table/" + table + "/query", "{}").status()).as(table).isEqualTo(200); + } + for(String process : qInstance.getProcesses().keySet().stream().filter(name -> name.startsWith("assistant")).toList()) + { + assertThat(((Map) metadata.json().get("processes")).containsKey(process)).isFalse(); + assertThat(((Map) uiMetadata.json().get("processes")).containsKey(process)).isTrue(); + assertThat(invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", process).withJsonBody(Map.of())).status()).as(process).isEqualTo(403); + assertThat(ui(base, "POST", "/processes/" + process + "/init", "{}").status()).as(process).isEqualTo(200); + } + assertThat(UiApiTestServer.SCHEDULED_REQUESTS).anySatisfy(request -> assertThat(request.get("client")).isEqualTo("assistant/42")); + } + + + + /******************************************************************************* + ** Spoofed headers cannot turn an assistant call into a Next UI request. + *******************************************************************************/ + private UiApiResult invoke(UiApiCall call) throws Exception + { + return (config.getRuntime().getUiApiGateway().invoke(call.withSessionUUID(AssistantTestFixture.ADMIN).withClientDetail("42").withHeader("X-QQQ-Client", "next-ui"))); + } + + + + /******************************************************************************* + ** The same administrator's direct browser request. + *******************************************************************************/ + private UiApiResult ui(String base, String method, String path, String body) throws Exception + { + HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN) + .header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json") + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + return (new UiApiResult(response.statusCode(), response.body())); + } + + + + /******************************************************************************* + ** Enumerate rather than maintaining an allowlist that could miss new objects. + *******************************************************************************/ + private List objects() + { + List objects = new ArrayList<>(); + objects.addAll(qInstance.getTables().values()); + objects.addAll(qInstance.getProcesses().values()); + objects.addAll(qInstance.getReports().values()); + objects.addAll(qInstance.getWidgets().values()); + return (objects.stream().filter(object -> object.getName().startsWith("assistant")).toList()); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RecordHistoryConfigTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RecordHistoryConfigTest.java new file mode 100644 index 0000000..bbf2ebc --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/RecordHistoryConfigTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant; + + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Compatibility seam for the future core audit-read opt-in. + *******************************************************************************/ +public class RecordHistoryConfigTest +{ + /******************************************************************************* + ** RC.6 changes exactly one pattern, preserving every other channel rule. + *******************************************************************************/ + @Test + void fallbackRemovesOnlyAuditPattern() throws Exception + { + ClientChannelsMetaData channels = ClientChannelsMetaData.defaults(); + channels.withDeniedPattern("assistant", "audit_private*").withDeniedPattern("next-ui", "audit*"); + channels.withDeniedObject("assistant", "auditDetail"); + List expected = new ArrayList<>(channels.getDeniedPatterns().get("assistant")); + expected.remove("audit*"); + enable(channels); + enable(channels); + assertThat(channels.getDeniedPatterns().get("assistant")).containsExactlyElementsOf(expected) + .contains("worm_audit_*", "assistant_*", "audit_private*"); + assertThat(channels.getDeniedPatterns().get("next-ui")).contains("audit*"); + assertThat(channels.getDeniedObjects().get("assistant")).contains("auditDetail"); + } + + + + /******************************************************************************* + ** Lookup uses the future method name and does not run the fallback afterward. + *******************************************************************************/ + @Test + void invokesFutureCoreMethod() throws Exception + { + FutureChannels channels = new FutureChannels(); + channels.withDeniedPattern("assistant", "audit*"); + enable(channels); + assertThat(channels.called).isTrue(); + assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*"); + } + + + + /******************************************************************************* + ** A broken future core method must fail startup rather than weaken the wall. + *******************************************************************************/ + @Test + void invocationFailureDoesNotFallBack() + { + FutureChannels channels = new FutureChannels(); + channels.fail = true; + channels.withDeniedPattern("assistant", "audit*"); + assertThatThrownBy(() -> enable(channels)).cause().isInstanceOf(IllegalStateException.class); + assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*"); + } + + + + /******************************************************************************* + ** Exercise the one private compatibility helper without widening production API. + *******************************************************************************/ + private void enable(ClientChannelsMetaData channels) throws Exception + { + Method method = AssistantQBitProducer.class.getDeclaredMethod("enableRecordHistory", ClientChannelsMetaData.class); + method.setAccessible(true); + method.invoke(new AssistantQBitProducer(), channels); + } + + + + /******************************************************************************* + ** A future core API stand-in; RC.6 metadata is not final. + *******************************************************************************/ + public static class FutureChannels extends ClientChannelsMetaData + { + private Boolean called = false; + private Boolean fail = false; + + + + /******************************************************************************* + ** Record reflective dispatch; deliberately leave the pattern to detect fallback. + *******************************************************************************/ + public ClientChannelsMetaData withAssistantAuditReads() + { + called = true; + if(fail) + { + throw (new IllegalStateException("test failure")); + } + return (this); + } + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java index 0867730..9a5f7cd 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java @@ -41,8 +41,10 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage; import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.UiApiTestServer; import com.kingsrook.qbits.assistant.tools.KnowledgeTool; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; +import com.kingsrook.qbits.assistant.tools.UiApiToolbox; import com.kingsrook.qbits.assistant.uiapi.UiApiCall; import com.kingsrook.qqq.backend.core.actions.ai.AiExposure; import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; @@ -54,6 +56,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -278,6 +281,25 @@ class RagTest extends BaseAssistantTest + /******************************************************************************* + ** Discovery and RAG record reads preserve turn details and request credentials. + *******************************************************************************/ + @Test + void attributesKnowledgeRequestsToTurn() + { + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config)); + UiApiTestServer.serve(qInstance, config); + UiApiTestServer.SCHEDULED_REQUESTS.clear(); + config.getRuntime().getContextBuilder().build(QContext.getQSession(), "model", null, List.of(), "question", Map.of(), PolicyService.ToolPolicy.defaults(), null, "42"); + UiApiToolbox.Context context = new UiApiToolbox.Context("invalid-session", null, config.getRuntime().getUiApiGateway(), + PolicyService.ToolPolicy.defaults(), null, UiApiCredentials.ofSessionUUID(AssistantTestFixture.EDITOR), "42"); + assertThat(config.getRuntime().getRagService().read(context, "blog_post", "1").getResult().getOutcome()).isEqualTo(AiToolOutcome.OK); + assertThat(UiApiTestServer.SCHEDULED_REQUESTS).hasSizeGreaterThanOrEqualTo(2).allSatisfy(request -> + assertThat(request.get("client")).isEqualTo("assistant/42")); + } + + + /******************************************************************************* ** UI read permission is checked again for every vector hit, with no identifiers ** or snippets leaking from denied records. Exposure is reused from get_record. diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java index 635b60c..1b36b2d 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java @@ -145,7 +145,7 @@ public class AssistantTestFixture .withField(new QFieldMetaData("diagnosis", QFieldType.STRING))); } - new AuditsMetaDataProvider().defineAll(qInstance, BACKEND, null); + new AuditsMetaDataProvider().withClientChannelField(true).defineAll(qInstance, BACKEND, null); for(String auditTable : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL, AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT_USER)) { qInstance.getTable(auditTable).setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java index a7518cd..7038df6 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java @@ -66,8 +66,16 @@ class UiApiToolsSecurityTest extends BaseAssistantTest ** The default channel wall hides history, even when the user can read audits. *******************************************************************************/ @Test - void historyHiddenByDefault() + void historyHiddenByDefault() throws Exception { + assertThat(config.getRecordHistory()).isFalse(); + UiApiTestServer.serve(qInstance, config); + for(String table : List.of("auditTable", "audit", "auditDetail")) + { + UiApiResult result = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query") + .withPathParam("tableName", table).withJsonBody(Map.of()).withSessionUUID(AssistantTestFixture.EDITOR)); + assertThat(result.status()).isEqualTo(403); + } assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); assertThat(tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12")).getHttpStatus()).isEqualTo(403); } @@ -78,9 +86,10 @@ class UiApiToolsSecurityTest extends BaseAssistantTest ** A host opt-in offers history and reads it through the real UI API. *******************************************************************************/ @Test - void historyOfferedAndReadableAfterHostOptIn() + void historyOfferedAndReadableAfterHostOptIn() throws Exception { - ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*"); + config.getRuntime().close(); + qInstance = AssistantTestFixture.build(config.withRecordHistory(true)); assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).contains(UiApiToolbox.RECORD_HISTORY); UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12", "since", "THIS_WEEK")); assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK); @@ -95,10 +104,11 @@ class UiApiToolsSecurityTest extends BaseAssistantTest *******************************************************************************/ @ParameterizedTest @ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL }) - void historyRespectsEachAuditTableDenial(String name) + void historyRespectsEachAuditTableDenial(String name) throws Exception { + config.getRuntime().close(); + qInstance = AssistantTestFixture.build(config.withRecordHistory(true)); ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance); - channels.getDeniedPatterns().get("assistant").remove("audit*"); channels.withDeniedObject("assistant", name); assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); channels.getDeniedObjects().get("assistant").remove(name); @@ -321,7 +331,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest assertThat(log).hasSize(1); assertThat(log.get(0).getUiApiOperation()).isEqualTo("POST /table/{tableName}"); assertThat(log.get(0).getHttpStatus()).isBetween(200, 299); - assertThat(log.get(0).getAuditHref()).startsWith("/app/blog_post/"); + assertThat(log.get(0).getAuditHref()).startsWith("/app/audit?filter="); QContext.clear(); }