fix(store): the assistant works for people who do not administer it where core enforces table permissions #102

Merged
james.maes merged 1 commits from feat/store-reads-as-trusted-code into develop 2026-11-11 17:10:43 +00:00
Owner

Why

On a host that enforces table permissions inside core actions (QInstance.enforcePermissionsInCoreActions), only an assistant administrator could use the assistant. A person with assistant.hasAccess and without assistant.admin was refused before any model call: "Not authorized to access the assistant admin data." (seen at BudgetService.evaluate -> AssistantStore.budgets). The assistant reads its settings and writes the person's own rows through tables whose permission rule is assistant.admin, with the person's session. A host without core enforcement never checked that rule for the store, so the qbit's own tests did not see it.

What ran with the person's table permissions, and what it is now

AssistantStore is the only package allowed to call QQQ data actions (ArchUnit). Every call it makes is now marked as the assistant's own service code (withPermissionBypass with a reason), the way qbit-collaboration and qbit-backups do. The session stays the person's in every case.

What the store touches Kind Before, where core enforces Now
Policy, providers, models, model prices, profiles and skills, data access settings, knowledge sources The assistant's configuration Refused without assistant.admin Read as the assistant's own code. Never returned to the person
Budgets, overrides, budget events, the shared budget accounting Configuration and accounting Refused Read to decide; events and accounting written, attributed to the asking person
Conversations The person's own data (owner lock, access rule) Allowed Unchanged: the person's session and the owner lock
Messages, turns, turn events, tool calls, pending actions, feedback, client errors, model calls (usage), daily usage, the data audit The person's own data (owner lock) under an assistant.admin table rule Refused Written and read with the person's session; the owner lock keeps other people's rows out

What does not change:

  • Owner record locks. Core does not relax record locks for a bypass; a person asking the store for another person's conversation, messages or model calls gets no row.
  • The tables' permission rules. The tables stay closed to that person in the UI, the UI API and the assistant's tools (the AI exposure of every assistant table is NONE).
  • Who may call. Every operation of the assistant API requires assistant access first; the save rules of budgets and overrides require assistant.admin themselves.
  • No secret is returned: GET /config answers what it did, plus the person's own budgets.

A person's own budget

  • GET /config has budgets (UI API extension assistant 1.8.0, additive): the budgets scoped to that user or to a permission that user holds, with used, limit, percent, state, startsAgainAt and a sentence. Budgets shared by several people (INSTANCE, PROVIDER, MODEL) are not listed: their count is other people's spending too.
  • The same lines ride beside the person's question (<assistant_budget>, in the new message, not in the cached blocks), so "how much of my budget have I used?" has an answer.

Tests

CoreEnforcementTest (the fixture instance with core enforcement on), five tests:

  • A non-administrator's question with a tool call completes, and the model call is accounted to them.
  • The settings tables refuse that person through a core query, and the assistant's tools do not reach them.
  • One person cannot read another's conversation (API 404), messages or model calls (the store finds no row).
  • A person sees their own spending against their own limit in /config and beside their question; another person's figure is their own; the shared budget and the other person's amounts are absent; somebody without assistant access is told nothing.
  • A person at their own daily limit is refused with the sentence (HTTP 429); another person is answered.

Run without the store change, the first, second, third and fifth failed at the first store read. The fourth (own budget) was written afterwards.

./mvnw -B verify: BUILD SUCCESS, every module, no skip flags. One earlier run failed in TranscriptRedactionTest.expandedTokensSurviveMetadataValidation: its canaries are random UUIDs and it refuses any five-character window of them anywhere in storage, and that run's window was the digits 47278, which also occurred in a stored number. The test passed in three reruns and in the full run after. It is a chance collision in the test, not a leak; worth making the canary non-numeric.

🤖 Generated with Claude Code

## Why On a host that enforces table permissions inside core actions (`QInstance.enforcePermissionsInCoreActions`), only an assistant administrator could use the assistant. A person with `assistant.hasAccess` and without `assistant.admin` was refused before any model call: "Not authorized to access the assistant admin data." (seen at `BudgetService.evaluate` -> `AssistantStore.budgets`). The assistant reads its settings and writes the person's own rows through tables whose permission rule is `assistant.admin`, with the person's session. A host without core enforcement never checked that rule for the store, so the qbit's own tests did not see it. ## What ran with the person's table permissions, and what it is now `AssistantStore` is the only package allowed to call QQQ data actions (ArchUnit). Every call it makes is now marked as the assistant's own service code (`withPermissionBypass` with a reason), the way qbit-collaboration and qbit-backups do. The session stays the person's in every case. | What the store touches | Kind | Before, where core enforces | Now | |---|---|---|---| | Policy, providers, models, model prices, profiles and skills, data access settings, knowledge sources | The assistant's configuration | Refused without `assistant.admin` | Read as the assistant's own code. Never returned to the person | | Budgets, overrides, budget events, the shared budget accounting | Configuration and accounting | Refused | Read to decide; events and accounting written, attributed to the asking person | | Conversations | The person's own data (owner lock, access rule) | Allowed | Unchanged: the person's session and the owner lock | | Messages, turns, turn events, tool calls, pending actions, feedback, client errors, model calls (usage), daily usage, the data audit | The person's own data (owner lock) under an `assistant.admin` table rule | Refused | Written and read with the person's session; the owner lock keeps other people's rows out | What does not change: - Owner record locks. Core does not relax record locks for a bypass; a person asking the store for another person's conversation, messages or model calls gets no row. - The tables' permission rules. The tables stay closed to that person in the UI, the UI API and the assistant's tools (the AI exposure of every assistant table is NONE). - Who may call. Every operation of the assistant API requires assistant access first; the save rules of budgets and overrides require `assistant.admin` themselves. - No secret is returned: `GET /config` answers what it did, plus the person's own budgets. ## A person's own budget - `GET /config` has `budgets` (UI API extension assistant 1.8.0, additive): the budgets scoped to that user or to a permission that user holds, with `used`, `limit`, `percent`, `state`, `startsAgainAt` and a sentence. Budgets shared by several people (INSTANCE, PROVIDER, MODEL) are not listed: their count is other people's spending too. - The same lines ride beside the person's question (`<assistant_budget>`, in the new message, not in the cached blocks), so "how much of my budget have I used?" has an answer. ## Tests `CoreEnforcementTest` (the fixture instance with core enforcement on), five tests: - A non-administrator's question with a tool call completes, and the model call is accounted to them. - The settings tables refuse that person through a core query, and the assistant's tools do not reach them. - One person cannot read another's conversation (API 404), messages or model calls (the store finds no row). - A person sees their own spending against their own limit in `/config` and beside their question; another person's figure is their own; the shared budget and the other person's amounts are absent; somebody without assistant access is told nothing. - A person at their own daily limit is refused with the sentence (HTTP 429); another person is answered. Run without the store change, the first, second, third and fifth failed at the first store read. The fourth (own budget) was written afterwards. `./mvnw -B verify`: BUILD SUCCESS, every module, no skip flags. One earlier run failed in `TranscriptRedactionTest.expandedTokensSurviveMetadataValidation`: its canaries are random UUIDs and it refuses any five-character window of them anywhere in storage, and that run's window was the digits `47278`, which also occurred in a stored number. The test passed in three reruns and in the full run after. It is a chance collision in the test, not a leak; worth making the canary non-numeric. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-11 17:04:13 +00:00
fix(store): the assistant works for people who do not administer it where core enforces table permissions
maven / build (push) Successful in 4m54s
build / maven (push) Successful in 4m54s
6cbd530719
On a host with QInstance.enforcePermissionsInCoreActions on, a person with
assistant.hasAccess and without assistant.admin was refused before any model
call ("Not authorized to access the assistant admin data."). The assistant
reads its settings (policy, providers, models, prices, budgets, data access)
and writes that person's messages, turns, tool calls, pending actions and
usage through tables whose permission rule is assistant.admin, with the
person's session. On a host without core enforcement nothing checked that
rule for the store, so it was never seen.

AssistantStore, the only package allowed to call QQQ data actions, now marks
every one of its calls as the assistant's own service code
(withPermissionBypass with a reason), the way qbit-collaboration and
qbit-backups do. Only the table permission check is skipped, for those calls:

 - the session stays the person's: writes are attributed to them, and the
   owner record locks keep every other person's rows out (core does not relax
   record locks for a bypass);
 - the tables' permission rules are unchanged: the tables stay closed to that
   person in the UI, the UI API and the assistant's tools;
 - who may call is decided before the store: every operation of the assistant
   API requires assistant access, and the save rules of budgets and overrides
   require assistant.admin themselves.

A person's own budget. GET /config has "budgets" (UI API extension assistant
1.8.0): the budgets scoped to that user or to a permission that user holds,
with used, limit, percent, state and when the count starts again. Budgets
shared by several people are not listed. The same lines ride beside the
person's question (<assistant_budget>), so "how much of my budget have I
used?" has an answer.

CoreEnforcementTest runs on the fixture instance with core enforcement on.
Run without the store change, four of its five tests failed at the first
store read; the fifth (own budget) was written afterwards.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes merged commit e02658de7e into develop 2026-11-11 17:10:43 +00:00
Sign in to join this conversation.