test: cover user-owned session security boundaries
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
@@ -92,6 +94,74 @@ def test_admin_can_read_another_owners_session(database_url):
|
||||
assert admin.get(session_id).manifest.id == session_id
|
||||
|
||||
|
||||
def test_owner_admin_and_foreign_principals_have_distinct_mutation_boundaries(database_url):
|
||||
session_id = str(uuid.uuid4())
|
||||
alice = _repository(database_url, f"alice-{session_id}")
|
||||
bob = _repository(database_url, f"bob-{session_id}")
|
||||
admin = _repository(database_url, f"admin-{session_id}", is_admin=True)
|
||||
manifest = _manifest(session_id)
|
||||
alice.create(manifest)
|
||||
alice.write_artifact(session_id, "question", "Alice's question")
|
||||
|
||||
assert [snapshot.manifest.id for snapshot in alice.list()] == [session_id]
|
||||
assert bob.list() == []
|
||||
for mutation in (
|
||||
lambda: bob.write_artifact(session_id, "question", "Bob's overwrite"),
|
||||
lambda: bob.append_decisions(
|
||||
session_id, [DecisionInput(type="concept_clarified", subject="bob")]
|
||||
),
|
||||
lambda: bob.save_manifest(manifest.model_copy(update={"name": "Bob's rename"})),
|
||||
lambda: bob.delete(session_id),
|
||||
):
|
||||
with pytest.raises(SessionError, match="Sessione non trovata"):
|
||||
mutation()
|
||||
|
||||
# Admin access is an explicit, audited exception to normal owner isolation.
|
||||
admin.write_artifact(session_id, "question", "Reviewed by admin")
|
||||
admin.append_decisions(
|
||||
session_id, [DecisionInput(type="concept_clarified", subject="admin-review")]
|
||||
)
|
||||
|
||||
owner_snapshot = alice.get(session_id)
|
||||
assert owner_snapshot.artifacts["question"] == "Reviewed by admin"
|
||||
assert [record.subject for record in owner_snapshot.decisions] == ["admin-review"]
|
||||
assert admin.get(session_id).manifest.id == session_id
|
||||
|
||||
|
||||
def test_concurrent_postgres_ledger_mutations_keep_every_decision_in_sequence(database_url, monkeypatch):
|
||||
from tht.session.postgres_repository import PostgresSessionRepository
|
||||
|
||||
repository = _repository(database_url, "alice")
|
||||
session_id = str(uuid.uuid4())
|
||||
repository.create(_manifest(session_id))
|
||||
barrier = Barrier(2)
|
||||
original_lock = PostgresSessionRepository._lock_session
|
||||
|
||||
def enter_lock_together(connection, locked_session_id):
|
||||
if locked_session_id == session_id:
|
||||
barrier.wait(timeout=5)
|
||||
original_lock(connection, locked_session_id)
|
||||
|
||||
monkeypatch.setattr(
|
||||
PostgresSessionRepository,
|
||||
"_lock_session",
|
||||
staticmethod(enter_lock_together),
|
||||
)
|
||||
|
||||
def append(subject: str):
|
||||
return repository.append_decisions(
|
||||
session_id, [DecisionInput(type="concept_clarified", subject=subject)]
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first, second = pool.map(append, ("first", "second"))
|
||||
|
||||
snapshot = repository.get(session_id)
|
||||
assert {first[0].seq, second[0].seq} == {1, 2}
|
||||
assert [record.seq for record in snapshot.decisions] == [1, 2]
|
||||
assert {record.subject for record in snapshot.decisions} == {"first", "second"}
|
||||
|
||||
|
||||
def test_non_superuser_runtime_login_can_assume_the_restricted_runtime_role(database_url):
|
||||
admin = create_engine(database_url)
|
||||
runtime_url = admin.url.set(
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from tht.config import DatabaseConfig, load_config
|
||||
from tht.decisions import DecisionInput
|
||||
from tht.session.filesystem_repository import FilesystemSessionRepository
|
||||
from tht.session.models import PrincipalContext, SessionManifest
|
||||
from tht.session.models import PrincipalContext, SessionManifest, local_principal
|
||||
from tht.session.repository import build_session_repository, resolve_principal
|
||||
from tht.session.store import SessionError
|
||||
from tht.session.store import create_session
|
||||
@@ -105,6 +105,25 @@ def test_filesystem_repository_keeps_preferences_per_principal(tmp_path):
|
||||
assert bob.get_preferences() == {}
|
||||
|
||||
|
||||
def test_two_local_homes_have_independent_identities_sessions_and_preferences(tmp_path):
|
||||
alice_home = tmp_path / "alice-home"
|
||||
bob_home = tmp_path / "bob-home"
|
||||
alice = FilesystemSessionRepository(alice_home, "demo", local_principal(alice_home))
|
||||
bob = FilesystemSessionRepository(bob_home, "demo", local_principal(bob_home))
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
alice.create(_manifest(session_id))
|
||||
alice.set_preferences({"model": "glm"})
|
||||
|
||||
assert alice.principal.subject != bob.principal.subject
|
||||
assert (alice.root / session_id / "session_manifest.yaml").exists()
|
||||
assert not (bob.root / session_id).exists()
|
||||
assert bob.list() == []
|
||||
assert bob.get_preferences() == {}
|
||||
with pytest.raises(SessionError, match="Sessione non trovata"):
|
||||
bob.get(session_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_id", ["2026-01-01-000000-test", "2026-01-01-000000-x", "s1"])
|
||||
def test_filesystem_repository_reads_safe_legacy_session_ids(tmp_path, session_id):
|
||||
repository = FilesystemSessionRepository(
|
||||
|
||||
Reference in New Issue
Block a user