Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
26cda1b
feat: limit visibility to private and public and map simple_token to …
alex-feel Oct 3, 2026
b6c2eea
feat: add access scope types and the access predicate builder
alex-feel Oct 3, 2026
4958b7a
test: add the two-principal access scope test scaffold
alex-feel Oct 3, 2026
6969368
feat: scope deduplication and store to the calling principal
alex-feel Oct 3, 2026
32315da
feat: scope context reads, existence probes and prefix resolution
alex-feel Oct 3, 2026
f1a6b3c
feat: scope context search and grep to readable entries
alex-feel Oct 3, 2026
bd1e639
feat: authorize updates against ownership and write grants
alex-feel Oct 3, 2026
391532a
feat: authorize deletes by ownership through a single delete path
alex-feel Oct 3, 2026
cd27839
feat: scope semantic, full-text and hybrid search to readable entries
alex-feel Oct 3, 2026
578ee75
feat: scope thread listing and statistics to the caller
alex-feel Oct 3, 2026
bbffb7e
test: add a structural completeness test for access scoping
alex-feel Oct 3, 2026
8132a91
test: cover access scoping through real servers and HTTP JWT on both …
alex-feel Oct 4, 2026
b3e3d1e
feat: add an owner reassignment mode to the migrate CLI
alex-feel Oct 4, 2026
dcebff5
docs: document per-principal access scoping and owner reassignment
alex-feel Oct 4, 2026
d54937a
fix: keep scoped SQLite reads on covering indexes
alex-feel Oct 4, 2026
35a5d4d
docs: correct the visibility-change error and owner-stamping guidance
alex-feel Oct 4, 2026
2c9885b
chore: remove the unused ImageRepository.store_image
alex-feel Oct 4, 2026
8c483d8
chore: split helpers out of the batch update tool module
alex-feel Oct 4, 2026
fc59392
fix: keep the remaining SQLite statistics counts on covering indexes
alex-feel Oct 4, 2026
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
62 changes: 49 additions & 13 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A high-performance Model Context Protocol (MCP) server providing persistent mult
- **Multimodal Context Storage**: Store and retrieve both text and images
- **UUIDv7 Context Identifiers**: Every context entry is identified by a 32-character lowercase hex UUIDv7 value, providing time-ordered, globally unique IDs with a stable lex-string ordering
- **Thread-Based Scoping**: Agents working on the same task share context through thread IDs
- **Per-Record Access Control**: Every entry has an owner, a `private` or `public` visibility, and optional user or group grants, enforced on every read, update and delete on both SQLite and PostgreSQL. With IdP-issued JWTs, each caller finds, counts and changes only the entries it may access. See the [Access Model](docs/authentication.md#access-model)
- **Flexible Metadata Filtering**: Store custom structured data with any JSON-serializable fields and filter using 16 powerful operators
- **Date Range Filtering**: Filter context entries by creation timestamp using ISO 8601 format
- **Tag-Based Organization**: Efficient context retrieval with normalized, indexed tags
Expand Down
230 changes: 230 additions & 0 deletions app/access_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Caller scopes and the SQL access predicate for context-entry statements.

A scope names whose ``context_entries`` rows a statement may touch: an
:class:`AccessScope` built from the request's effective principal, or
:data:`SYSTEM_SCOPE` for schema maintenance that runs outside any request.
:func:`build_access_predicate` turns a scope into the SQL fragment that limits a
statement to the rows the scope may read, modify or owns:

- ``READ``: the caller owns the row, the row is ``public``, or a ``read`` or
``write`` grant names the caller or one of its groups.
- ``WRITE``: the caller owns the row, or a ``write`` grant names the caller or
one of its groups.
- ``OWNER``: the caller owns the row.

:func:`build_readable_parent_predicate` renders the ``READ`` predicate for a child
row instead: the child's key must name an entry the scope may read.

A grant carries no visibility condition, so it is effective under either
visibility, and a write grant implies read. Comparisons are exact and
case-sensitive, matching how owner ids and grant principals are stored.

The predicate is a standalone fragment that never passes through the metadata
query builder or its filter counters, so it never counts as a client filter and
never draws on the metadata bind budget. Its bind count is constant per mode on
both backends (three for ``READ`` and ``WRITE``, one for ``OWNER``, none for the
system scope) because the group set binds as ONE parameter: a text array on
PostgreSQL, a JSON array expanded by ``json_each`` on SQLite. An empty group set
therefore needs no special case and never produces an empty ``IN ()`` list.

This module imports only the standard library, so the repositories and
:mod:`app.ids` can import it without pulling in settings or the MCP framework.
"""

import json
from dataclasses import dataclass
from enum import StrEnum
from typing import final


@dataclass(frozen=True, slots=True)
class AccessScope:
"""The identity a request reads and writes context entries as.

Attributes:
principal_id: The caller's principal id, matched against ``owner_id``
and against user grants.
groups: The caller's group ids, matched against group grants.
"""

principal_id: str
groups: frozenset[str]

def groups_list(self) -> list[str]:
"""Return the groups sorted, so the bound value is deterministic across processes.

Returns:
The group ids in ascending order.
"""
return sorted(self.groups)

def groups_json(self) -> str:
"""Return the sorted groups as a JSON array, the SQLite group bind.

Returns:
The JSON text of :meth:`groups_list`.
"""
return json.dumps(self.groups_list())


@final
class SystemScope:
"""Unrestricted scope for schema maintenance that runs outside any request.

It carries no principal and is never built from request data.
"""

__slots__ = ()


SYSTEM_SCOPE = SystemScope()

type Scope = AccessScope | SystemScope


class AccessMode(StrEnum):
"""Which rows a predicate admits."""

READ = 'read'
WRITE = 'write'
OWNER = 'owner'


@dataclass(frozen=True, slots=True)
class AccessPredicate:
"""A SQL fragment restricting a statement to the rows a scope may access.

Attributes:
sql: Empty for the system scope; otherwise one boolean expression with
no leading ``AND``, parenthesized whenever it has more than one arm.
params: Values to bind, in placeholder order: text order on SQLite,
``$start`` upward on PostgreSQL.
"""

sql: str
params: list[str | list[str]]

@property
def bind_count(self) -> int:
"""Number of parameters the predicate binds, the same on both backends."""
return len(self.params)

def and_clause(self) -> str:
"""Return the predicate as an ``AND`` term to append to an existing ``WHERE``.

Returns:
``' AND <sql>'``, or an empty string for the system scope.
"""
return f' AND {self.sql}' if self.sql else ''

def where_clause(self) -> str:
"""Return the predicate as a complete ``WHERE`` clause.

Returns:
``' WHERE <sql>'``, or an empty string for the system scope.
"""
return f' WHERE {self.sql}' if self.sql else ''


def build_access_predicate(
scope: Scope,
*,
mode: AccessMode,
backend_type: str,
outer: str,
start: int = 1,
) -> AccessPredicate:
"""Build the predicate limiting a statement to the rows ``scope`` may access in ``mode``.

The principal binds twice for ``READ`` and ``WRITE`` (the owner arm and the
user-grant arm) on both backends, so callers advance their placeholder
position by ``bind_count`` with the same arithmetic everywhere.

Args:
scope: The caller's scope; the system scope yields an empty predicate.
mode: ``READ``, ``WRITE`` or ``OWNER``.
backend_type: ``'sqlite'`` or ``'postgresql'``, the backend's ``backend_type``.
outer: The table name or alias of ``context_entries`` in the enclosing
statement; it qualifies ``id``, ``owner_id`` and ``visibility``,
because the grant subquery's own table also has an ``id`` column.
It must be a trusted identifier, never request data.
start: The first PostgreSQL ``$n`` the predicate may use; ignored on SQLite.

Returns:
The predicate text and its parameters.
"""
if isinstance(scope, SystemScope):
return AccessPredicate(sql='', params=[])

principal = scope.principal_id
if backend_type == 'sqlite':
owner_placeholder = '?'
grantee_placeholder = '?'
group_membership = 'g.principal_id IN (SELECT value FROM json_each(?))'
groups_param: str | list[str] = scope.groups_json()
else: # postgresql
owner_placeholder = f'${start}'
grantee_placeholder = f'${start + 1}'
group_membership = f'g.principal_id = ANY(${start + 2}::text[])'
groups_param = scope.groups_list()

owner_arm = f'{outer}.owner_id = {owner_placeholder}'
if mode is AccessMode.OWNER:
return AccessPredicate(sql=owner_arm, params=[principal])

if mode is AccessMode.READ:
public_arm = f" OR {outer}.visibility = 'public'"
permission = "g.permission IN ('read', 'write')"
else: # WRITE
public_arm = ''
permission = "g.permission = 'write'"
grant_arm = (
f'EXISTS (SELECT 1 FROM context_entry_grants g WHERE g.context_entry_id = {outer}.id AND {permission} '
f"AND ((g.principal_type = 'user' AND g.principal_id = {grantee_placeholder}) "
f"OR (g.principal_type = 'group' AND {group_membership})))"
)
return AccessPredicate(
sql=f'({owner_arm}{public_arm} OR {grant_arm})',
params=[principal, principal, groups_param],
)


def build_readable_parent_predicate(
scope: Scope,
*,
child_key: str,
parent_key: str,
backend_type: str,
start: int = 1,
) -> AccessPredicate:
"""Build the predicate limiting child rows to those whose parent entry ``scope`` may read.

The predicate tests the child's key for membership in the keys of the readable
entries: ``<child_key> IN (SELECT ce.<parent_key> FROM context_entries ce WHERE <READ>)``.
SQLite statements use it in place of a join to each child's parent row. A join
looks each parent up by ``id`` or ``rowid_int``, and neither lookup holds
``owner_id`` or ``visibility``, so every child row reads its parent's table row and
walks the text overflow pages stored before those columns; the key set instead comes
from one scan of a covering access index. The parameters are those of the ``READ``
predicate.

Args:
scope: The caller's scope; the system scope yields an empty predicate.
child_key: The child column holding the parent key, qualified by the child's
name or alias in the enclosing statement. It must be a trusted identifier,
never request data.
parent_key: The ``context_entries`` column ``child_key`` references, a trusted
identifier: ``id``, or ``rowid_int`` for the SQLite FTS index.
backend_type: ``'sqlite'`` or ``'postgresql'``, the backend's ``backend_type``.
start: The first PostgreSQL ``$n`` the predicate may use; ignored on SQLite.

Returns:
The predicate text and its parameters.
"""
read = build_access_predicate(scope, mode=AccessMode.READ, backend_type=backend_type, outer='ce', start=start)
if not read.sql:
return read
return AccessPredicate(
sql=f'{child_key} IN (SELECT ce.{parent_key} FROM context_entries ce WHERE {read.sql})',
params=read.params,
)
5 changes: 4 additions & 1 deletion app/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
- app.auth.simple_token: SimpleTokenVerifier implementation
- app.auth.claims: IdP-agnostic claim extraction helpers
- app.auth.principal: per-request RequestPrincipal resolution
- app.auth.access: effective-principal resolution and the publish gate
- app.auth.access: effective-principal and access-scope resolution and the
publish gate (the scope types live in app.access_scope)
- app.settings.auth.AuthSettings: Authentication configuration
- FastMCP authentication docs: https://gofastmcp.com/servers/auth
"""
Expand All @@ -37,6 +38,7 @@
import logging
from typing import TYPE_CHECKING

from app.auth.access import resolve_access_scope
from app.auth.access import resolve_effective_principal
from app.auth.access import visibility_denied_reason
from app.auth.principal import RequestPrincipal
Expand All @@ -57,6 +59,7 @@
'RequestPrincipal',
'SimpleTokenVerifier',
'create_auth_provider',
'resolve_access_scope',
'resolve_effective_principal',
'resolve_request_principal',
'visibility_denied_reason',
Expand Down
45 changes: 31 additions & 14 deletions app/auth/access.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
"""Access-control policy helpers for the tool write paths.
"""Access-control policy helpers for the tool layer.

This module turns the transport-level principal (:mod:`app.auth.principal`)
into the identity the storage layer stamps, and holds the publish-gate rule.
It is the single place that applies ``AccessControlSettings`` policy:
into the identity the storage layer stamps on new rows and the scope that
limits which rows a request reaches, and holds the publish-gate rule. It is the
single place that applies ``AccessControlSettings`` policy:

- :func:`resolve_effective_principal` maps the no-token case (stdio transport,
or HTTP with ``MCP_AUTH_PROVIDER=none``) to the configured default principal
with empty groups and roles, so every request -- authenticated or not --
resolves to exactly one owner identity.
- :func:`resolve_effective_principal` maps every request without a jwt
identity (stdio transport, and HTTP with ``MCP_AUTH_PROVIDER`` ``none`` or
``simple_token``) to the configured default principal with empty groups and
roles, so every request -- authenticated or not -- resolves to exactly one
owner identity.
- :func:`resolve_access_scope` returns that same identity as the
:class:`~app.access_scope.AccessScope` that limits which context entries the
request may read and write.
- :func:`visibility_denied_reason` enforces ``ACCESS_CONTROL_PUBLISH_ROLE``:
when the setting names a role, only callers whose verified roles claim
carries it may set visibility 'public'.
"""

from app.access_scope import AccessScope
from app.auth.principal import RequestPrincipal
from app.auth.principal import resolve_request_principal
from app.settings import get_settings
Expand All @@ -22,9 +28,9 @@ def resolve_effective_principal() -> RequestPrincipal:
"""Resolve the identity to stamp as ``owner_id`` for the current request.

Returns:
The verified request principal when the request carries a verified
access token, else the configured ``ACCESS_CONTROL_DEFAULT_PRINCIPAL``
with empty groups and roles.
The verified request principal when the jwt provider verified the
request's access token, else the configured
``ACCESS_CONTROL_DEFAULT_PRINCIPAL`` with empty groups and roles.
"""
principal = resolve_request_principal()
if principal is not None:
Expand All @@ -37,13 +43,24 @@ def resolve_effective_principal() -> RequestPrincipal:
)


def resolve_access_scope() -> AccessScope:
"""Resolve the scope the current request reads and writes context entries as.

Returns:
The effective principal's id and groups: the verified jwt principal's,
or ``ACCESS_CONTROL_DEFAULT_PRINCIPAL`` with no groups for every other
request.
"""
return resolve_effective_principal().access_scope()


def visibility_denied_reason(visibility: str, principal: RequestPrincipal) -> str | None:
"""Check the publish gate for a requested visibility value.

Only 'public' is gated: 'private' and 'shared' are always available to the
writer (for a store) or the owner (for an update; ownership is checked by
the caller, not here). When ``ACCESS_CONTROL_PUBLISH_ROLE`` is unset, any
owner may publish.
Only 'public' is gated: 'private' is always available to the writer (for a
store) or the owner (for an update; ownership is checked by the caller, not
here). When ``ACCESS_CONTROL_PUBLISH_ROLE`` is unset, any owner may
publish.

Args:
visibility: The requested visibility value.
Expand Down
Loading
Loading