test: cover user-owned session security boundaries
This commit is contained in:
@@ -51,6 +51,20 @@ test("upstream mode accepts only normalized proxy principal headers", async () =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("upstream mode rejects legacy client identity headers without proxy principal fields", async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
app.addHook("preHandler", authPreHandler("upstream"));
|
||||||
|
app.get("/me", async (req) => getPrincipal(req));
|
||||||
|
|
||||||
|
for (const headers of [
|
||||||
|
{ "x-authenticated-user": "mallory" },
|
||||||
|
{ "x-mock-user": "mallory" },
|
||||||
|
{ "x-authenticated-user": "mallory", "x-mock-user": "mallory" },
|
||||||
|
]) {
|
||||||
|
expect((await app.inject({ method: "GET", url: "/me", headers })).statusCode).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("local identity expands tilde homes and restores private POSIX permissions", () => {
|
test("local identity expands tilde homes and restores private POSIX permissions", () => {
|
||||||
expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test");
|
expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test");
|
||||||
expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester");
|
expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester");
|
||||||
|
|||||||
@@ -87,6 +87,41 @@ test("session listing permits all scope only to admins", async () => {
|
|||||||
expect(seen).toEqual([false, true]);
|
expect(seen).toEqual([false, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("A, B, and admin requests preserve owner isolation through session route mutations", async () => {
|
||||||
|
const owners = new Map([["a", "alice"], ["b", "bob"]]);
|
||||||
|
const closed: Array<{ id: string; subject: string }> = [];
|
||||||
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: {
|
||||||
|
withPrincipal: (principal: any) => ({
|
||||||
|
sessionList: async () => [...owners]
|
||||||
|
.filter(([, owner]) => principal.isAdmin || owner === principal.subject)
|
||||||
|
.map(([id, owner]) => ({ id, author: owner })),
|
||||||
|
sessionShow: async (id: string) =>
|
||||||
|
(principal.isAdmin || owners.get(id) === principal.subject) ? { id, status: "open" } : null,
|
||||||
|
closeSession: async (id: string) => { closed.push({ id, subject: principal.subject }); },
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
mgr: { get: () => undefined } as any,
|
||||||
|
hub: { clear: () => {} } as any,
|
||||||
|
getSettings: () => ({ workspace: "w" }) as any,
|
||||||
|
});
|
||||||
|
const bobHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "bob" };
|
||||||
|
const adminHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
||||||
|
|
||||||
|
expect((await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders })).json())
|
||||||
|
.toEqual([{ id: "a", author: "alice" }]);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/sessions", headers: bobHeaders })).json())
|
||||||
|
.toEqual([{ id: "b", author: "bob" }]);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/sessions?scope=all", headers: adminHeaders })).json())
|
||||||
|
.toEqual([{ id: "a", author: "alice" }, { id: "b", author: "bob" }]);
|
||||||
|
|
||||||
|
expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: bobHeaders })).statusCode)
|
||||||
|
.toBe(404);
|
||||||
|
expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: adminHeaders })).statusCode)
|
||||||
|
.toBe(200);
|
||||||
|
expect(closed).toEqual([{ id: "a", subject: "admin" }]);
|
||||||
|
});
|
||||||
|
|
||||||
test("new sessions are created through the authenticated principal, not a client owner field", async () => {
|
test("new sessions are created through the authenticated principal, not a client owner field", async () => {
|
||||||
let principal: any;
|
let principal: any;
|
||||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||||
@@ -1411,6 +1446,24 @@ test("POST /sessions readiness failure returns one fixed public message without
|
|||||||
expect(createdCalled).toBe(false);
|
expect(createdCalled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POST /sessions returns storage 503 before creating a Pi runtime when session persistence fails", async () => {
|
||||||
|
let piCreated = false;
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: {
|
||||||
|
ollamaEnsure: async () => ({ ok: true }),
|
||||||
|
sessionNew: async () => { throw new Error("database unavailable"); },
|
||||||
|
} as any,
|
||||||
|
getSettings: () => ({ workspace: "psd" }) as any,
|
||||||
|
mgr: { createFor: () => { piCreated = true; throw new Error("must not spawn"); } } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json()).toEqual({ error: "session storage is unavailable" });
|
||||||
|
expect(piCreated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
||||||
let ensureWs: string | undefined;
|
let ensureWs: string | undefined;
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from threading import Barrier
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, text
|
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
|
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):
|
def test_non_superuser_runtime_login_can_assume_the_restricted_runtime_role(database_url):
|
||||||
admin = create_engine(database_url)
|
admin = create_engine(database_url)
|
||||||
runtime_url = admin.url.set(
|
runtime_url = admin.url.set(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from tht.config import DatabaseConfig, load_config
|
from tht.config import DatabaseConfig, load_config
|
||||||
from tht.decisions import DecisionInput
|
from tht.decisions import DecisionInput
|
||||||
from tht.session.filesystem_repository import FilesystemSessionRepository
|
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.repository import build_session_repository, resolve_principal
|
||||||
from tht.session.store import SessionError
|
from tht.session.store import SessionError
|
||||||
from tht.session.store import create_session
|
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() == {}
|
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"])
|
@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):
|
def test_filesystem_repository_reads_safe_legacy_session_ids(tmp_path, session_id):
|
||||||
repository = FilesystemSessionRepository(
|
repository = FilesystemSessionRepository(
|
||||||
|
|||||||
@@ -285,7 +285,9 @@ class PostgresSessionRepository:
|
|||||||
ids = [row[0] for row in connection.execute(
|
ids = [row[0] for row in connection.execute(
|
||||||
text("SELECT id FROM thoth_sessions.sessions ORDER BY created_at DESC")
|
text("SELECT id FROM thoth_sessions.sessions ORDER BY created_at DESC")
|
||||||
).all()]
|
).all()]
|
||||||
return [self.get(session_id) for session_id in ids]
|
# psycopg2 materializes PostgreSQL UUID columns as ``uuid.UUID`` objects,
|
||||||
|
# while the repository boundary intentionally accepts canonical UUIDv4 text.
|
||||||
|
return [self.get(str(session_id)) for session_id in ids]
|
||||||
|
|
||||||
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot:
|
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot:
|
||||||
self._require_uuid4(manifest.id)
|
self._require_uuid4(manifest.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user