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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **`entries.updated` nullable**: column is now NULL on insert, set only on actual updates — aligns with `users.updated` semantics; sort and filter queries use `COALESCE(updated, created)` for consistency

### Security
- **`cleanup_expired` RLS-safe**: background cleanup now uses `SET LOCAL row_security = off` so expired entries are cleaned regardless of RLS enforcement
- **`clear()` scoped to owner**: `clear(owner_id)` deletes only that owner's data instead of truncating all tenants
- **Argon2 time_cost bumped to 3**: stronger password hashing for new and changed passwords (existing hashes remain valid)

### Fixed
- **PR label automation**: `Dev Active` is now a proper hold state — `on-push` and `on-ci-pass` skip pipeline transitions while it's present, `on-unlabel` handles promotion when it's removed
- **PR label automation**: `on-ci-pass` no longer fails on force-pushed PRs — `gh api` 404 errors handled gracefully
Expand Down
3 changes: 2 additions & 1 deletion benchmarks/semantic_search_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
EMBEDDING_MODEL = "nomic-embed-text"
EMBEDDING_DIM = 768

BENCH_OWNER = "bench-owner"
SCALE_TIERS = [500, 1_000, 5_000, 10_000]
# Tiers above this threshold use synthetic vectors (skip Ollama embedding)
REAL_EMBED_THRESHOLD = 1_000
Expand Down Expand Up @@ -540,7 +541,7 @@ def main() -> None:
}

# Clean up for next tier
store.clear()
store.clear(BENCH_OWNER)
# Close pool connections
store._pool.close()

Expand Down
2 changes: 1 addition & 1 deletion src/mcp_awareness/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def _user_set_password(dsn: str, args: argparse.Namespace) -> None:

break # password accepted

ph = PasswordHasher()
ph = PasswordHasher(time_cost=3)
hashed = ph.hash(password)
now = datetime.now(timezone.utc)
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
Expand Down
22 changes: 14 additions & 8 deletions src/mcp_awareness/postgres_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,14 @@ def _cleanup_expired(self) -> None:
self._cleanup_thread.start()

def _do_cleanup(self) -> None:
"""Run the actual DELETE using a pool connection (background thread)."""
"""Run the actual DELETE using a pool connection (background thread).

Uses SET LOCAL row_security = off because cleanup is a system-wide
maintenance task — expired entries should be cleaned regardless of owner.
"""
try:
with self._pool.connection() as conn, conn.cursor() as cur:
with self._pool.connection() as conn, conn.transaction(), conn.cursor() as cur:
cur.execute(_load_sql("disable_row_security"))
now = datetime.now(timezone.utc)
cur.execute(_load_sql("cleanup_expired"), (now,))
except Exception as exc:
Expand Down Expand Up @@ -1166,9 +1171,10 @@ def get_referencing_entries(self, owner_id: str, entry_id: str) -> list[Entry]:
(json.dumps([entry_id]),),
)

def clear(self) -> None:
with self._pool.connection() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM reads")
cur.execute("DELETE FROM actions")
cur.execute("DELETE FROM embeddings")
cur.execute("DELETE FROM entries")
def clear(self, owner_id: str) -> None:
with self._pool.connection() as conn, conn.transaction(), conn.cursor() as cur:
self._set_rls_context(cur, owner_id)
cur.execute(_load_sql("clear_reads"), (owner_id,))
cur.execute(_load_sql("clear_actions"), (owner_id,))
cur.execute(_load_sql("clear_embeddings"), (owner_id,))
cur.execute(_load_sql("clear_entries"), (owner_id,))
4 changes: 4 additions & 0 deletions src/mcp_awareness/sql/clear_actions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* name: clear_actions */
/* mode: literal */
/* Delete all actions for a given owner. Params: owner_id */
DELETE FROM actions WHERE owner_id = %s
4 changes: 4 additions & 0 deletions src/mcp_awareness/sql/clear_embeddings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* name: clear_embeddings */
/* mode: literal */
/* Delete all embeddings for a given owner. Params: owner_id */
DELETE FROM embeddings WHERE owner_id = %s
4 changes: 4 additions & 0 deletions src/mcp_awareness/sql/clear_entries.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* name: clear_entries */
/* mode: literal */
/* Delete all entries for a given owner. Params: owner_id */
DELETE FROM entries WHERE owner_id = %s
4 changes: 4 additions & 0 deletions src/mcp_awareness/sql/clear_reads.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* name: clear_reads */
/* mode: literal */
/* Delete all reads for a given owner. Params: owner_id */
DELETE FROM reads WHERE owner_id = %s
5 changes: 5 additions & 0 deletions src/mcp_awareness/sql/disable_row_security.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* name: disable_row_security */
/* mode: literal */
/* Disable RLS for the current transaction only (SET LOCAL).
Used by system-wide maintenance tasks that must operate across all owners. */
SET LOCAL row_security = off
4 changes: 2 additions & 2 deletions src/mcp_awareness/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,6 @@ def get_referencing_entries(self, owner_id: str, entry_id: str) -> list[Entry]:
"""Find entries whose data.related_ids contains the given entry_id."""
...

def clear(self) -> None:
"""Delete all entries, reads, actions, and embeddings."""
def clear(self, owner_id: str) -> None:
"""Delete all entries, reads, actions, and embeddings for an owner."""
...
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ def store(pg_dsn):
"""Fresh PostgresStore for each test — tables created, then cleared after."""
s = PostgresStore(pg_dsn)
yield s
s.clear()
s.clear(TEST_OWNER)
52 changes: 49 additions & 3 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import threading
import time

import pytest

from mcp_awareness.schema import Entry, EntryType, make_id, now_utc

TEST_OWNER = "test-owner"
Expand Down Expand Up @@ -467,8 +469,46 @@ def test_double_soft_delete_is_noop(store):

def test_clear(store):
store.upsert_status(TEST_OWNER, "nas", [], {"metrics": {}, "ttl_sec": 60})
store.clear()
store.clear(TEST_OWNER)
assert store.get_sources(TEST_OWNER) == []


def test_clear_isolates_owners(store):
"""clear(owner_id) must only delete that owner's data."""
other_owner = "other-owner"
store.upsert_status(TEST_OWNER, "nas", [], {"metrics": {}, "ttl_sec": 60})
store.upsert_status(other_owner, "nas", [], {"metrics": {}, "ttl_sec": 60})
store.clear(TEST_OWNER)
# TEST_OWNER data gone
assert store.get_sources(TEST_OWNER) == []
# Other owner's data intact
assert store.get_sources(other_owner) == ["nas"]
# Clean up other owner
store.clear(other_owner)


def test_cleanup_respects_rls(store):
"""_do_cleanup() must delete expired entries across all owners."""
from datetime import datetime, timedelta, timezone

other_owner = "other-owner"
past = datetime.now(timezone.utc) - timedelta(hours=1)
for owner in (TEST_OWNER, other_owner):
entry = Entry(
id=make_id(),
type=EntryType.SUPPRESSION,
source="test",
tags=[],
created=past,
expires=past,
data={"metric": "cpu", "suppress_level": "warning"},
)
store.add(owner, entry)
# Run cleanup directly (bypasses debounce)
store._do_cleanup()
# Expired entries for both owners should be gone
assert store.count_active_suppressions(TEST_OWNER) == 0
assert store.count_active_suppressions(other_owner) == 0


# ------------------------------------------------------------------
Expand Down Expand Up @@ -1162,7 +1202,7 @@ def test_clear_removes_reads_and_actions(store):
)
store.log_read(TEST_OWNER, [entry.id], tool_used="test")
store.log_action(TEST_OWNER, entry_id=entry.id, action="test")
store.clear()
store.clear(TEST_OWNER)
assert store.get_reads(TEST_OWNER) == []
assert store.get_actions(TEST_OWNER) == []

Expand Down Expand Up @@ -1923,7 +1963,7 @@ def test_cascade_delete_removes_embedding(self, store):
)
store.add(TEST_OWNER, entry)
store.upsert_embedding(TEST_OWNER, entry.id, "m", 768, "h1", self._vec(768, 0))
store.clear()
store.clear(TEST_OWNER)
results = store.semantic_search(TEST_OWNER, self._vec(768, 0), "m")
assert results == []

Expand Down Expand Up @@ -2519,6 +2559,12 @@ def test_compose_text_covers_entry_types(self, store):


class TestOwnerIsolation:
@pytest.fixture(autouse=True)
def _cleanup_owners(self, store):
yield
for owner in ("alice", "bob"):
store.clear(owner)

def test_entries_isolated_by_owner(self, store):
"""Entries from one owner are not visible to another."""
entry = Entry(
Expand Down
Loading