Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 ceeb134c90 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:42 -05:00
james.maes a15d66ecc7 Merge pull request #40 from QRun-IO/feature/x1-y1-channel-wall
feat: the hard wall between the assistant and its configuration, and audit attribution (X1/Y1)
2026-10-14 09:02:36 -05:00
james.maes 886ee06cc4 Merge remote-tracking branch 'origin/develop' into feature/record-history-opt-in
# Conflicts:
#	CHANGELOG.md
2026-10-14 08:58:56 -05:00
james.maesandClaude Opus 5.5 1020c3b324 feat: withRecordHistory(true) host opt-in for get_record_history
Default off (owner: hard wall; kof22 does not opt in). When on, calls
core's ClientChannelsMetaData.withAssistantAuditReads() (QRun-IO/qqq#1193:
audits readable, never writable, worm_audit_* still denied) through a
STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8) lookup, falling back on
RC.6 to removing exactly the audit* pattern. Other patterns are never
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:53:10 -05:00
james.maes 73b58d7391 Merge pull request #39 from QRun-IO/feature/email-qbit
feat(email): scheduled-result email adapter for qbit-email
2026-10-14 08:27:25 -05:00
james.maes 64268e63d0 Merge remote-tracking branch 'origin/develop' into feature/x1-y1-channel-wall
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 08:19:55 -05:00
james.maesandClaude Opus 5.5 b0b80ead3d feat: the hard wall and audit attribution (X1/Y1)
X1:
- Every assistant_* table, process, report and widget (and every object
  the qbit produces) is denied to client channel assistant: the
  assistant_* pattern in ClientChannelsMetaData plus
  QPermissionRules.withDeniedClients on each object.
- Startup fails if any of them is not denied.
- Every UI API call carries assistant/<turnId> (scheduled:
  assistant/scheduled/<taskId>); confirm and explain-access carry the
  owning turn.
- Tests: assistant.admin through the assistant is denied (403, absent
  from list_tables and /metaData) on every configuration object, while the
  same admin through next-ui is allowed; the startup check fails on one
  undenied object; core's security-surface patterns are present.
Y1:
- assistant_tool_call.audit_href: an /app link to the written record's
  audit rows filtered by clientChannel, built with the shared filter-link
  builder.
- Tests: an assistant write audits the human actor with
  clientChannel assistant/<turnId>; the same write from the UI audits
  next-ui. Hosts enable the audit clientChannel field (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:17:35 -05:00
32 changed files with 969 additions and 69 deletions

No files matched your search

+17
View File
@@ -1,9 +1,26 @@
# 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
- Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and status mapping.
- Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC using core's `DataClassification`, owner-recipient hook, opt-in and `emailExternalApproved` gates. Scheduled-task wiring remains a separate integration step.
## Unreleased - Provider API extraction (#30)
+1
View File
@@ -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/<turnId>` or `assistant/scheduled/<taskId>`; 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).
+46 -6
View File
@@ -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/<turnId>`, `assistant/scheduled/<taskId>` 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**:
@@ -470,7 +501,7 @@ Apache 2.0.
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`.
This integration uses qbit-email **0.1.0-RC.1**. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
Gates, in order (each returns a status and queues nothing):
1. the task opted in (`NOT_REQUESTED`);
@@ -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.
+21 -2
View File
@@ -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/<taskId>`.
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
+8
View File
@@ -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/<turnId>`; 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/<taskId>`.
- 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:
+4 -1
View File
@@ -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;
+2 -2
View File
@@ -29,7 +29,7 @@
<archunit.version>1.5.1</archunit.version>
<jackson.version>2.21.7</jackson.version>
<!-- optional email delivery; qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
<test.excludedGroups>live</test.excludedGroups>
</properties>
@@ -37,7 +37,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -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
*******************************************************************************/
@@ -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<AssistantQBit
QAppMetaData app = builder.buildAdminApp(tables);
qInstance.addApp(app);
AssistantChannelWall.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
{
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
}
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8): call withAssistantAuditReads() directly once the qbit builds on a QQQ RC that has it
*******************************************************************************/
private void enableRecordHistory(ClientChannelsMetaData channels)
{
try
{
channels.getClass().getMethod("withAssistantAuditReads").invoke(channels);
}
catch(NoSuchMethodException e)
{
List<String> 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.
*******************************************************************************/
@@ -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;
@@ -103,7 +103,17 @@ public class AccessExplainer
*******************************************************************************/
public List<TableAccess> 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<TableAccess> 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())));
@@ -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<String, Object> 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<String, Object> access(String table, String prompt, Integer turnId) throws QException, AssistantApiException
{
requireAccess();
if(turnId != null)
{
requireTurn(turnId);
}
List<Map<String, Object>> 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)
{
@@ -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<Parameter> 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")));
}
}
@@ -55,7 +55,7 @@ public final class AssistantEmail
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
}
catch(ClassNotFoundException | LinkageError e)
{
@@ -24,13 +24,11 @@ package com.kingsrook.qbits.assistant.email;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -66,7 +64,7 @@ final class AssistantEmailSender
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
return (EmailApi.isInstalled(qInstance));
}
@@ -79,7 +77,7 @@ final class AssistantEmailSender
{
boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL);
EmailRequest request = new EmailRequest().withTo(recipient)
EmailApiRequest request = new EmailApiRequest().withTo(recipient)
.withIdempotencyKey("assistant:scheduled:" + notice.runId())
.withLinkedRecord(notice.runTable(), notice.runId())
.withSender(SENDER_QBIT, SENDER_PROCESS);
@@ -100,7 +98,7 @@ final class AssistantEmailSender
}
request.withVariables(variables);
return (map(EmailService.send(request)));
return (map(EmailApi.send(request)));
}
@@ -123,7 +121,7 @@ final class AssistantEmailSender
/*******************************************************************************
** Outbox result to the qbit's own vocabulary.
*******************************************************************************/
private static ScheduledEmailStatus map(EmailSendResult result)
private static ScheduledEmailStatus map(EmailApiResult result)
{
return (switch(result.status())
{
@@ -141,10 +139,10 @@ final class AssistantEmailSender
*******************************************************************************/
static void ensureTemplate() throws QException
{
EmailTemplates.ensure(new EmailTemplateDefinition()
EmailApi.ensureTemplate(new EmailApiTemplate()
.withName(TEMPLATE_NAME)
.withLabel("Assistant scheduled task result")
.withCategory(EmailCategory.NOTIFICATION)
.withCategory(EmailApiCategory.NOTIFICATION)
.withKind("MARKDOWN")
.withOwnerQbit(SENDER_QBIT)
.withSubject("Assistant task result: {{taskName}}")
@@ -106,6 +106,16 @@ public class ContextBuilder
** kernel's navigate and search_help.
*******************************************************************************/
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
{
return (tools(policy, null));
}
/*******************************************************************************
** Attribute tool-discovery requests to the turn that needs them.
*******************************************************************************/
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId)
{
Map<String, ModelToolDefinition> 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<AssistantMessage> history, String userText, Map<String, Object> 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<AssistantMessage> history, String userText, Map<String, Object> 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));
@@ -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())
@@ -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<String> 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<MetaDataWithPermissionRules> objects(QInstance qInstance)
{
List<MetaDataWithPermissionRules> 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());
}
}
@@ -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)
@@ -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)));
}
@@ -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<Map<String, Object>> criteria)
{
return (AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(Map.of("criteria", criteria, "booleanOperator", "AND")), StandardCharsets.UTF_8));
}
}
@@ -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)
@@ -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)));
}
@@ -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))
@@ -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:
@@ -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<String, Object> result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId());
assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
String channel = "assistant/" + turn.getId();
List<QRecord> 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<AssistantToolCall> 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<QRecord> 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<QRecord> 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<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return (new UiApiResult(response.statusCode(), response.body()));
}
}
@@ -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<MetaDataWithPermissionRules> 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<String> 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<MetaDataWithPermissionRules> objects()
{
List<MetaDataWithPermissionRules> 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());
}
}
@@ -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<String> 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);
}
}
}
@@ -26,14 +26,15 @@ import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
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.insert.InsertInput;
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.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
@@ -101,8 +102,8 @@ class AssistantEmailTest
}
if(withEmail)
{
QContext.init(qInstance, new QSession().withPermissions(EmailPermissions.ALL));
new InsertAction().execute(new InsertInput(EmailTables.PROVIDER).withRecords(List.of(new QRecord().withValue("name", "relay").withValue("label", "Relay")
QContext.init(qInstance, new QSession().withPermissions(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess")));
new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(new QRecord().withValue("name", "relay").withValue("label", "Relay")
.withValue("kind", "SMTP").withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE")
.withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true))));
}
@@ -125,9 +126,20 @@ class AssistantEmailTest
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> messages() throws Exception
private List<EmailApiMessage> messages() throws Exception
{
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
return (EmailApi.findMessagesByIdempotencyKeyPrefix("assistant:scheduled:", 100));
}
/*******************************************************************************
** Inspect the test outbox through core as the authorized email administrator.
** The public email API deliberately excludes bodies and recipient addresses.
*******************************************************************************/
private QRecord storedMessage() throws Exception
{
return (new QueryAction().execute(new QueryInput("email_message")).getRecords().getFirst());
}
@@ -142,18 +154,20 @@ class AssistantEmailTest
assertThat(AssistantEmail.isAvailable(instance)).isTrue();
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
List<QRecord> messages = messages();
List<EmailApiMessage> messages = messages();
assertThat(messages).hasSize(1);
QRecord message = messages.get(0);
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
EmailApiMessage metadata = messages.getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:7");
assertThat(metadata.linkedTableName()).isEqualTo(RUN_TABLE);
assertThat(metadata.linkedRecordId()).isEqualTo("7");
assertThat(metadata.templateName()).isEqualTo("assistant_scheduled_result");
QRecord message = storedMessage();
assertThat(message.getValueString("toAddresses")).isEqualTo("owner@example.com");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:7");
assertThat(message.getValueString("linkedTableName")).isEqualTo(RUN_TABLE);
assertThat(message.getValueString("linkedRecordId")).isEqualTo("7");
assertThat(message.getValueString("senderQbit")).isEqualTo("assistant");
assertThat(message.getValueString("subject")).contains("Weekly review");
assertThat(message.getValueString("htmlBody")).contains("Three items need attention.").contains("https://app.example.com/app/assistant_scheduled_run/7");
QRecord template = SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter()).stream().filter(t -> "assistant_scheduled_result".equals(t.getValueString("name"))).findFirst().orElseThrow();
QRecord template = new QueryAction().execute(new QueryInput("email_template")).getRecords().stream().filter(t -> "assistant_scheduled_result".equals(t.getValueString("name"))).findFirst().orElseThrow();
assertThat(template.getValueString("category")).isEqualTo("NOTIFICATION");
assertThat(template.getValueString("ownerQbit")).isEqualTo("assistant");
assertThat(message.getValueInteger("templateId")).isEqualTo(template.getValueInteger("id"));
@@ -173,13 +187,15 @@ class AssistantEmailTest
{
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(8, "Patient Jane Doe has diabetes", DataClassification.PHI, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
QRecord message = messages().get(0);
assertThat(message.getValueString("category")).isEqualTo("SECURITY");
EmailApiMessage metadata = messages().getFirst();
assertThat(metadata.category()).isEqualTo(EmailApiCategory.SECURITY);
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:8");
assertThat(metadata.linkedRecordId()).isEqualTo("8");
assertThat(metadata.templateName()).isEqualTo("email_secure_message");
QRecord message = storedMessage();
assertThat(message.getValueString("htmlBody")).contains("https://app.example.com/app/assistant_scheduled_run/8").doesNotContain("Jane Doe").doesNotContain("Weekly review");
assertThat(message.getValueString("textBody")).doesNotContain("Jane Doe");
assertThat(message.getValueString("subject")).doesNotContain("Weekly review");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("assistant:scheduled:8");
assertThat(message.getValueString("linkedRecordId")).isEqualTo("8");
}
@@ -201,7 +217,7 @@ class AssistantEmailTest
config.withEmailRecipientResolver(id -> "user-17".equals(id) ? " user17@example.com " : null);
assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.QUEUED);
assertThat(messages().get(0).getValueString("toAddresses")).isEqualTo("user17@example.com");
assertThat(storedMessage().getValueString("toAddresses")).isEqualTo("user17@example.com");
}
@@ -230,7 +246,7 @@ class AssistantEmailTest
assertThat(AssistantEmailSender.summary("x".repeat(1000))).hasSize(280).endsWith("...");
AssistantEmail.sendScheduledResult(instance, config, notice(5, "y".repeat(2000), null, true));
assertThat(messages().get(0).getValueString("htmlBody")).doesNotContain("y".repeat(281));
assertThat(storedMessage().getValueString("htmlBody")).doesNotContain("y".repeat(281));
}
@@ -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.
@@ -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));
@@ -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();
}