Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceeb134c90
|
||
|
|
a15d66ecc7 | ||
|
|
886ee06cc4
|
||
|
|
1020c3b324
|
||
|
|
73b58d7391 | ||
|
|
64268e63d0
|
||
|
|
b0b80ead3d
|
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
+11
-1
@@ -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)
|
||||
{
|
||||
|
||||
+3
-3
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
{
|
||||
|
||||
+11
-13
@@ -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}}")
|
||||
|
||||
+23
-2
@@ -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())
|
||||
|
||||
+96
@@ -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());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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)
|
||||
|
||||
+1
-2
@@ -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:
|
||||
|
||||
+192
@@ -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()));
|
||||
}
|
||||
}
|
||||
+179
@@ -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());
|
||||
}
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-21
@@ -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.
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+16
-6
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user