Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,122 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]

Tenant isolation and safe filters on top of core's tenant columns (`tenant_columns=` on
`PgCheckpointer` / `PgVectorStore`) and filter language (`tenxgraph.storage.sql_filter`).
Needs the `10xgraph` release that ships them.

### Breaking

- **Security:** `RoleBasedAuthorizationBackend(isolation=...)` (and `authorization.isolation`
in `10xgraph.json` for the `rbac` backend) raises `ValueError` for anything other than
`"owner"`, `"none"` or a `tenxgraph.core.authz.Isolation` member, so the server refuses to
boot. An unknown value such as `"owners"` used to become `"owner"` silently.
- **Security:** `resolve_isolation_scope` gives a backend without an `isolation_scope` method
`"owner"` (the `AuthorizationBackend` default, fail closed). It used to give `"none"`.
- An `isolation_scope(user)` result that is an unknown string now fails the request (403):
core's `build_authz` raises for it instead of turning it into `"none"`.
- Client `filters` on an extra column whose type is not one of the six built-in types
(`jsonb`, `text[]`, `numeric(10,2)`, ...) accept only `is_null`; anything else is a 422 with
core's message.

### Added

- `POST /v1/store/memories/list` takes `filters`, validated like search and forget (a tenant
column or `user_id` is a 422, reserved owner keys are stripped). Filters only narrow the
list; the isolation scope still applies. Without `filters` the store call is unchanged.
- `AuthorizationBackend.isolation_scope(user)` is called per request with the authenticated
user and may return `"owner"`, `"none"` or a list of columns (`"user_id"` and declared
tenant columns, e.g. `["org_id"]` for an org admin). The result is stamped into the trusted
`authz` block for core. Backends that define `isolation_scope(self)` with no argument keep
working (the signature decides how it is called). A malformed column list is a 403, never
"no isolation".
- `GET /v1/threads?filters=<JSON object>`: column conditions on `thread_name`, `created_at`,
`updated_at` and the checkpointer's extra columns, validated with core's `validate_filters`
before the listing runs. Bad JSON, an unknown column or operator, a tenant column or
`user_id`, and a checkpointer without filterable columns (in-memory, SQLite) are 422 with a
message that never echoes the input and survives production.
- Store routes (`/v1/store/search`, `/v1/store/memories/forget`) validate `filters` with the
same validator when the store has a `filter_schema` (`PgVectorStore`): a tenant column,
`user_id` or a bad condition is a 422 before the store (or the embedding call) runs. Stores
without one (`QdrantStore`) are unchanged.

### Changed

- `OwnershipAuthorizationBackend` (and `RoleBasedAuthorizationBackend`) compare a thread with
the caller on every column the caller's scope picks, through
`BaseCheckpointer.aget_thread_scope` (`aget_thread_owner` for checkpointers without it).
Two tenants with the same `user_id` can no longer reach each other's threads. The caller's
values come only from the authenticated user (`config["user"]`); a picked column the thread
or the caller lacks, or one the checkpointer does not declare, denies.
- With `isolation: "none"`, `RoleBasedAuthorizationBackend` now lets any authenticated user
reach an existing thread (no scope column to compare), matching the unscoped storage that
setting already gave. Unowned threads keep the create-only rule.
- The ownership cache holds the thread's scope values. Without tenant columns the shared cache
entry keeps its old layout (`owner:id:<thread>`, plain owner); with them it is a JSON object
under `owner:scope:<thread>`, so an owner-only entry is never read as a full scope. A cached
entry missing a compared column is looked up again. A thread deleted by someone other than
its owner (an org admin) leaves a tombstone that blocks everyone.
- With tenant columns declared, cached thread scopes live in the cache store for 5 minutes
(`TENANT_L2_TTL_SECONDS`) instead of an hour, so a delete whose cache eviction failed leaves
the old scope readable for minutes, not an hour. Without tenant columns the TTL stays 1 h.

### Security

- Uploaded files are scoped by tenant columns as well as `user_id`. With tenant columns
declared on the bound checkpointer (`PgCheckpointer(tenant_columns=...)`), an upload records
the caller's values (from `config["user"]` only, converted with the column types) in the
file's metadata under `tenant`, and every file path requires them to match the caller's:
download, `/info`, `/url`, `DELETE`, `GET /v1/files`, `graph://media/` and `file_id`
references in invoke, stream, WebSocket, AG-UI, `PUT state` and `POST messages`, thread
links and the per-user quota. Before, `alice` in `org-b` could list, read and reference the
files of `alice` in `org-a`, and both shared one quota. Fails closed: an identity without a
tenant value gets 403 on upload and 404 on reads, and a file with no recorded tenant values
(uploaded before this release, or ownerless) is denied while tenant columns are declared,
whatever `MEDIA_REQUIRE_OWNER` says. To keep such files, backfill their metadata with
`{"tenant": {"<column>": "<value>"}}` (canonical text, e.g. lowercase UUID). Without tenant
columns nothing changes and no key is written.

### Fixed

- Deleting a thread now releases the files linked to it by the thread's owner, read with
`aget_thread_scope` before the delete, as well as the deleter's. Before, an org admin (a scope
without `user_id`) or a `none`-scope deleter released only their own files, so the member's
linked files stayed behind. `media.delete_with_thread` still decides whether a file left
with no thread is deleted.
- `auth` `{"method": "custom", "path": "module:attr"}` now loads a top-level module in the
project folder (`auth_app:TokenAuth`), a dotted package path and a file path form
(`auth/custom.py:Cls`). Before, `auth_app:TokenAuth` failed with "Custom auth path does not
exist" and `auth_app.py:TokenAuth` with "No module named auth_app.py". A path that truly
does not exist still raises the same error naming it.
- `POST /v1/store/search`: `retrieval_strategy` and `distance_metric` now default to `None`, so the store's own settings apply. They were forced to `similarity` / `cosine`, which broke a text-only or HYBRID `PgVectorStore` and a Qdrant store with another distance.

- **Security:** a store bound from the `store` key in `10xgraph.json` skipped core's
checkpointer/store tenant column check (it never goes through `compile()`). The loader now
runs `check_tenant_columns` against the graph's checkpointer (and the one bound in the
container): `MODE=production` refuses the boot, other modes warn. New key
`store_allow_tenant_mismatch` (bool, default `false`) opts out, like
`compile(allow_tenant_mismatch=True)`, for a store holding only shared data. It is in the
config editor's Memory store section.
- **Security:** the thread delete tombstone exempted the deleter by `user_id` only, so the same
`user_id` in another tenant could recreate a just-deleted id while another worker still
cached the old tenant's scope. With tenant columns the tombstone stores the deleter's
`user_id` and tenant values (canonical JSON under `owner:tomb:<thread>`) and only a caller
matching all of them is exempt. `OwnershipAuthorizationBackend.evict` takes the deleting
`user=`; `CheckpointerService.delete_thread` passes it when the backend accepts it. Without
tenant columns the tombstone keeps its plain `user_id` layout. A plain tombstone written by
an older worker during a rolling deploy blocks everyone when tenant columns are declared.
- A cache store error while evicting a deleted thread's scope or writing its tombstone was
swallowed at debug level. It is now a warning, `ThreadOwnershipResolver.evict` raises
`OwnershipCacheEvictionError`, and `DELETE /v1/threads/{thread_id}` reports `"cache"` in
`cleanup_failed`.
- Stale docs said AG-UI and `/v1/graph/live` pass `client_thread_ids=True`; they follow
`runs.thread_ids` like REST since G-113. The `client_thread_ids` authorize context flag stays
for custom callers.

---

## [1.0.0rc4] - 2026-11-01

The fourth release candidate of `10xgraph-api` 1.0. It requires `10xgraph` 1.0.0rc4
Expand Down
30 changes: 25 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,11 @@ Parsed by `tenxgraph_api/src/app/core/config/graph_config.py`. Supported keys:
| `checkpointer` | `"module:attr"` -> a `BaseCheckpointer` |
| `injectq` | `"module:attr"` -> an InjectQ container |
| `store` | `"module:attr"` -> a `BaseStore` |
| `store_allow_tenant_mismatch` | Boolean, default false. The loader runs core `check_tenant_columns` on the store bound from `store` against the graph checkpointer (and a container-bound one): a tenant-column mismatch refuses to boot under `MODE=production` and warns otherwise. `true` logs the mismatch at INFO and boots (for a store that holds only shared data, such as a RAG knowledge base) |
| `cache_store` | One object, `graph_config.CacheStoreConfig`: `type` (`memory` default \| `redis` \| `custom`), `url` (type redis only, optional: empty uses the `REDIS_URL` environment variable, a set value wins; `$VAR` / `${VAR}` expanded when the store is built, unset variable is a config error; `RedisCacheStore`, also Valkey, Dragonfly, KeyDB), `path` (type custom only, required; `"module:attr"` -> a `BaseCacheStore` instance or zero-arg factory), `rate_limit` (object, see below), `token_revocation` (see its row). No key or no `type`: `InMemoryCacheStore` (per process). Unknown keys are config errors listing the valid ones. Nothing else picks the store (`REDIS_URL` alone does not). Selected and bound at boot by `core/cache_store/loader.py::load_and_bind_cache_store` (one INFO line `Cache store: <class> (<config key>, <endpoint>)`, e.g. `cache_store.url`), closed once in the lifespan; type redis without the `redis` package refuses to boot. Holds the run locks and caps, idempotency, ownership cache L2, token revocation and rate-limit counters. Top-level `redis`, `rate_limit` and `token_revocation` keys are config errors at `GraphConfig` load (`check_cache_store_settings`) saying where the setting goes |
| `ag_ui` | `{"enabled": bool}`, default off. Mounts `POST /v1/ag-ui` (AG-UI protocol, for CopilotKit and other AG-UI clients). Needs the `ag-ui` extra |
| `websocket` | `enabled` (`false` leaves out `/v1/graph/ws` and `/v1/graph/live`; `true` without `websockets`/`wsproto` refuses to boot; unset keeps them and warns if no library; `routers/setup_router.py::_graph_router`), `max_connections` (1000), `max_connections_per_user` (10), `realtime_models` (models a `/v1/graph/live` client may pick), `realtime_client_overrides` (init options a client may set; default `voice`, `modalities`, `vad`; add `system_prompt` only for trusted callers) |
| `runs` | `RunsConfig`: `thread_ids` (`server` default = only existing ids may be named on REST invoke/stream, `/v1/graph/ws` and checkpointer writes, a new thread is started by omitting the id; enforced by the ownership/rbac backends via `OwnershipAuthorizationBackend(server_thread_ids=...)`. AG-UI and `/v1/graph/live` are exempt: they pass `client_thread_ids=True` to `authorize` because their frontends make their own ids, and the caller owns a new one. `client` = any request may name a new thread, explicit opt-in), `config_keys` / `state_keys` (allow-lists for request config and `initial_state`/PUT state; 422 on other keys; `thread_id`, `run_id` always allowed), `max_concurrent_per_user` (HTTP invoke/stream/AG-UI runs in flight, default 10, 429 over it; `routers/graph/run_limits.py`), `timeout` (seconds; invoke 504, streams end with an error chunk), `max_parallel_tool_calls` (positive int, default unset = core's default 10 or env `TENXGRAPH_MAX_PARALLEL_TOOL_CALLS`; set into every run's config in `_prepare_input`, reserved from clients) |
| `runs` | `RunsConfig`: `thread_ids` (`server` default = only existing ids may be named on REST invoke/stream, `/v1/graph/ws` and checkpointer writes, a new thread is started by omitting the id; enforced by the ownership/rbac backends via `OwnershipAuthorizationBackend(server_thread_ids=...)`. AG-UI and `/v1/graph/live` follow the same rule (a blank or `new` id gets a server id). `client` = any request may name a new thread, explicit opt-in), `config_keys` / `state_keys` (allow-lists for request config and `initial_state`/PUT state; 422 on other keys; `thread_id`, `run_id` always allowed), `max_concurrent_per_user` (HTTP invoke/stream/AG-UI runs in flight, default 10, 429 over it; `routers/graph/run_limits.py`), `timeout` (seconds; invoke 504, streams end with an error chunk), `max_parallel_tool_calls` (positive int, default unset = core's default 10 or env `TENXGRAPH_MAX_PARALLEL_TOOL_CALLS`; set into every run's config in `_prepare_input`, reserved from clients) |
| `cache_store.token_revocation` | `true`, `false`, `null` or `{"enabled", "token_lifetime", "user_ttl"}` (`graph_config.TokenRevocationConfig`; a string is a config error). Read through `GraphConfig.token_revocation`. Revocations live in the cache store (`core/auth/revocation.py`: `revoke_token`, `revoke_user`, `is_revoked`), checked by `RequirePermission` (401), before every `/v1/graph/ws` run, and before and every 30 s during a `/v1/graph/live` session (`REALTIME_RECHECK_INTERVAL`, close 1008); cache store errors fail closed (503), and an unreachable cache store refuses the boot |
| `media` | `delete_with_thread` (default true): delete an uploaded file when the last message or thread that references it is deleted. Links are recorded in the file's `threads` metadata (`MediaService.link_threads` / `unlink_threads`); files without a `threads` key (uploaded before link tracking) and stores without `update_metadata` (core older than the release that added it) are never auto-deleted. `false` still records links. Parsed by `graph_config.MediaConfig` |
| `graph_introspection` | `"open"` or `"scoped"` (default scoped in production): `GET /v1/graph`, `/v1/graph/tools`, `/v1/graph:StateSchema` need an explicit `graph:read` scope (`RequirePermission(..., introspection=True)`) |
Expand Down Expand Up @@ -125,9 +126,23 @@ valid ones (`RATE_LIMIT_KEYS`).
`allow_all` only with auth off in development (no identity, so nothing to authorize).
Selection lives in `loader._resolve_authorization_backend`; `RequirePermission` passes
`resource_id` (thread_id from path or body) to `authorize`.
- **Isolation defaults to owner.** `AuthorizationBackend.isolation_scope()` returns `"owner"`, so a
custom backend with only `authorize()` still scopes threads and memories per user; return
`"none"` on purpose to share (`DefaultAuthorizationBackend` / `allow_all` does).
- **Isolation defaults to owner.** `AuthorizationBackend.isolation_scope(user)` is called per request and
returns `"owner"` by default, so a custom backend with only `authorize()` still scopes threads and
memories per user; return `"none"` on purpose to share (`DefaultAuthorizationBackend` / `allow_all`
does), or a list of `user_id` and declared tenant columns (e.g. `["org_id"]` for an org admin).
Backends with the old no-argument `isolation_scope(self)` still work (signature decides, no
retry on `TypeError`). `core.authz.Isolation` is the `OWNER` / `NONE` StrEnum; an unknown scope
raises (`RoleBasedAuthorizationBackend(isolation=...)` raises at construction, so the server
refuses to boot). The result is stamped in `config["user"]["authz"]`. Tenant column values come
only from `config["user"]` (the `BaseAuth` result or JWT claims).
- **Ownership compares scope columns.** `OwnershipAuthorizationBackend` allows a thread when its
values (`BaseCheckpointer.aget_thread_scope`, cached by `ThreadOwnershipResolver` with the tenant
values) equal the caller's on every column the scope picks; a scope naming a column the
checkpointer does not declare denies (fails closed).
- **Client `filters`.** `GET /v1/threads?filters=<JSON>` (at most 8192 chars) and the store routes'
`filters` are checked by `core/auth/client_filters.py` with core's `sql_filter` validator before
any DB work: malformed JSON, non-filterable or tenant columns / `user_id`, a bad operator, or a
checkpointer without filterable columns answer 422 (`ClientInputError`), never echoing values.
- **Token claims are validated.** `JwtAuth` requires `user_id` to be a non-empty string or an int
(stored as a string), else 401. `token_revoked` checks `jti` even with an odd `user_id`.
Scopes come from `authorization.scopes_from_claims`: the `scope` claim (space-separated) and
Expand Down Expand Up @@ -209,7 +224,12 @@ valid ones (`RATE_LIMIT_KEYS`).
`GET /v1/files/{file_id}`, `/{file_id}/info`, `/{file_id}/url`, `DELETE /v1/files/{file_id}`
(owner only, 404 otherwise, scope `files:delete`, added in `authorization.FILES_DELETE_SCOPES`
until core lists it), `GET /v1/config/multimodal`. Deleting a message or thread also releases
the files it referenced (`CheckpointerService.delete_message` / `delete_thread`).
the files it referenced (`CheckpointerService.delete_message` / `delete_thread`; a thread delete
releases the thread owner's files too, read with `aget_thread_scope` before the delete). With
tenant columns on the bound checkpointer, files are tenant-scoped: upload records
`metadata["tenant"]` (canonical values from `config["user"]`, `authorization.identity_values`),
and access, list, quota, `graph://media/` / `file_id` references and `link_threads` require a
match on every tenant column (`media.tenant_matches`); a file without tenant values is denied.
- **AG-UI** (`tags=["AG-UI"]`, only when `ag_ui.enabled`): `POST /v1/ag-ui`. `routers/ag_ui/`:
`converter.py` (RunAgentInput -> new user/tool messages only; the checkpoint is the record),
`event_mapper.py` (StreamChunk -> AG-UI events), `service.py` (runs `GraphService.stream_chunks`).
Expand Down
Loading
Loading