"""Security isolation integration tests for department-scoped resources (U10). Verifies department-based access control end-to-end through the full request stack (route → DepartmentContext → filtering → response): - User in dept A cannot see skills/KB bound to dept B. - User in depts A+B sees the union of both departments' resources. - Admin sees all resources regardless of department bindings. - User removed from a department loses access to that department's resources. - Disabled department's resources are not visible to its users. - API-key client (no user_id) sees only global resources. - Non-admin user gets 403 on all admin endpoints. The tests mount a minimal FastAPI app with the ``skills`` and ``kb-management`` routers (for isolation verification) plus the ``admin_router`` (for the 403 checks). The ``get_department_context`` dependency is overridden per-test to simulate different callers. """ from __future__ import annotations import sqlite3 import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any import pytest from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from agentkit.server.admin.context import DepartmentContext from agentkit.server.auth.models import init_auth_db from agentkit.server.routes import admin as admin_routes_module from agentkit.server.routes import kb_management as kb_routes from agentkit.server.routes import skills as skills_routes from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.registry import SkillRegistry # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture async def tmp_auth_db(tmp_path: Path) -> Path: db_path = tmp_path / "security_isolation.db" await init_auth_db(db_path) return db_path @pytest.fixture def skill_registry() -> SkillRegistry: """A SkillRegistry pre-loaded with three test skills. - ``hr_skill`` — will be bound to department A. - ``eng_skill`` — will be bound to department B. - ``global_skill`` — has NO department binding (global). """ registry = SkillRegistry() for name in ("hr_skill", "eng_skill", "global_skill"): config = SkillConfig( name=name, agent_type="test_type", task_mode="llm_generate", description=f"Test skill {name}", prompt={"identity": name, "instructions": "test"}, ) registry.register(Skill(config=config)) return registry @pytest.fixture def kb_store(): """Reset the module-level KB source store singleton.""" kb_routes._source_store = kb_routes.KnowledgeSourceStore() return kb_routes._source_store @pytest.fixture def app( tmp_auth_db: Path, skill_registry: SkillRegistry, kb_store: kb_routes.KnowledgeSourceStore, ) -> FastAPI: """A FastAPI app with skills + kb-management + admin routers mounted. The ``get_department_context`` dependency is overridden per-test via ``app.dependency_overrides``. The default override is "unauthenticated caller". """ application = FastAPI() application.state.auth_db_path = str(tmp_auth_db) application.state.skill_registry = skill_registry application.include_router(skills_routes.router, prefix="/api/v1") application.include_router(kb_routes.router, prefix="/api/v1") application.include_router(admin_routes_module.admin_router, prefix="/api/v1") # Default: unauthenticated caller. application.dependency_overrides[skills_routes.get_department_context] = ( _unauthenticated_context ) application.dependency_overrides[kb_routes.get_department_context] = _unauthenticated_context # Default: admin access allowed (used only for the admin 403 test # which overrides this). application.dependency_overrides[admin_routes_module._require_admin] = lambda: ( _make_admin_user() ) return application @pytest.fixture def client(app: FastAPI) -> TestClient: return TestClient(app) # --------------------------------------------------------------------------- # Department-context dependency overrides # --------------------------------------------------------------------------- async def _unauthenticated_context(request: Request) -> DepartmentContext: """Simulate an unauthenticated caller (no current_user).""" return DepartmentContext(user_id=None, department_ids=[], is_admin=False) def _ctx_for_user( user_id: str | None, department_ids: list[str], is_admin: bool = False, ): """Build a dependency override returning a fixed DepartmentContext.""" async def _override(request: Request) -> DepartmentContext: return DepartmentContext( user_id=user_id, department_ids=list(department_ids), is_admin=is_admin, ) return _override def _set_caller( app: FastAPI, user_id: str | None, department_ids: list[str], is_admin: bool = False, ) -> None: """Install the dependency overrides for both skills + kb routers.""" override = _ctx_for_user(user_id, department_ids, is_admin) app.dependency_overrides[skills_routes.get_department_context] = override app.dependency_overrides[kb_routes.get_department_context] = override def _make_admin_user() -> dict[str, Any]: return {"user_id": "admin-1", "username": "admin", "role": "admin"} def _raise_forbidden() -> dict[str, Any]: raise HTTPException(status_code=403, detail="Admin permission required") # --------------------------------------------------------------------------- # DB helpers (synchronous sqlite3 — no event-loop mixing with TestClient) # --------------------------------------------------------------------------- def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _create_department(db_path: Path, name: str) -> str: dept_id = str(uuid.uuid4()) with sqlite3.connect(str(db_path)) as db: db.execute( "INSERT INTO departments (id, name, description, is_active, created_at) " "VALUES (?, ?, ?, ?, ?)", (dept_id, name, "", 1, _now_iso()), ) db.commit() return dept_id def _disable_department(db_path: Path, department_id: str) -> None: with sqlite3.connect(str(db_path)) as db: db.execute( "UPDATE departments SET is_active = 0 WHERE id = ?", (department_id,), ) db.commit() def _bind_skill(db_path: Path, department_id: str, skill_name: str) -> None: with sqlite3.connect(str(db_path)) as db: db.execute( "INSERT INTO department_skill_bindings (id, department_id, skill_name, created_at) " "VALUES (?, ?, ?, ?)", (str(uuid.uuid4()), department_id, skill_name, _now_iso()), ) db.commit() def _bind_kb(db_path: Path, department_id: str, kb_source_id: str) -> None: with sqlite3.connect(str(db_path)) as db: db.execute( "INSERT INTO department_kb_bindings (id, department_id, kb_source_id, created_at) " "VALUES (?, ?, ?, ?)", (str(uuid.uuid4()), department_id, kb_source_id, _now_iso()), ) db.commit() def _assign_user_to_department(db_path: Path, user_id: str, department_id: str) -> None: with sqlite3.connect(str(db_path)) as db: db.execute( "INSERT INTO user_departments (user_id, department_id, created_at) VALUES (?, ?, ?)", (user_id, department_id, _now_iso()), ) db.commit() def _remove_user_from_department(db_path: Path, user_id: str, department_id: str) -> None: with sqlite3.connect(str(db_path)) as db: db.execute( "DELETE FROM user_departments WHERE user_id = ? AND department_id = ?", (user_id, department_id), ) db.commit() # --------------------------------------------------------------------------- # Test fixture: departments A and B with skill/KB bindings # --------------------------------------------------------------------------- @pytest.fixture def dept_setup(tmp_auth_db: Path, kb_store: kb_routes.KnowledgeSourceStore): """Create departments A and B, bind skills and KB sources. Layout: - Department A: bound to ``hr_skill`` and KB source ``hr_kb`` - Department B: bound to ``eng_skill`` and KB source ``eng_kb`` - ``global_skill`` and ``global_kb`` have NO bindings (global) """ dept_a = _create_department(tmp_auth_db, "HR") dept_b = _create_department(tmp_auth_db, "Engineering") _bind_skill(tmp_auth_db, dept_a, "hr_skill") _bind_skill(tmp_auth_db, dept_b, "eng_skill") # global_skill intentionally has no binding. # Create KB sources in the in-memory store. hr_kb = kb_store.add_source("HR KB", "local", {}) eng_kb = kb_store.add_source("Engineering KB", "local", {}) global_kb = kb_store.add_source("Global KB", "local", {}) _bind_kb(tmp_auth_db, dept_a, hr_kb.id) _bind_kb(tmp_auth_db, dept_b, eng_kb.id) # global_kb intentionally has no binding. return { "dept_a": dept_a, "dept_b": dept_b, "hr_kb_id": hr_kb.id, "eng_kb_id": eng_kb.id, "global_kb_id": global_kb.id, } # --------------------------------------------------------------------------- # Department isolation tests # --------------------------------------------------------------------------- class TestDepartmentIsolation: """Security tests for department-based resource isolation.""" def test_user_cannot_see_other_department_skills( self, app: FastAPI, client: TestClient, dept_setup: dict ): """User in dept A cannot see skills bound to dept B.""" _set_caller(app, user_id="alice", department_ids=[dept_setup["dept_a"]]) resp = client.get("/api/v1/skills") assert resp.status_code == 200 names = {s["name"] for s in resp.json()} # Alice sees hr_skill (dept A) + global_skill, NOT eng_skill (dept B). assert "hr_skill" in names assert "global_skill" in names assert "eng_skill" not in names def test_user_cannot_see_other_department_kb( self, app: FastAPI, client: TestClient, dept_setup: dict ): """User in dept A cannot see KB sources bound to dept B.""" _set_caller(app, user_id="alice", department_ids=[dept_setup["dept_a"]]) resp = client.get("/api/v1/kb-management/sources") assert resp.status_code == 200 ids = {s["id"] for s in resp.json()["sources"]} # Alice sees hr_kb (dept A) + global_kb, NOT eng_kb (dept B). assert dept_setup["hr_kb_id"] in ids assert dept_setup["global_kb_id"] in ids assert dept_setup["eng_kb_id"] not in ids def test_user_in_multiple_departments_sees_union( self, app: FastAPI, client: TestClient, dept_setup: dict ): """User in depts A+B sees skills from both.""" _set_caller( app, user_id="alice", department_ids=[dept_setup["dept_a"], dept_setup["dept_b"]], ) resp = client.get("/api/v1/skills") assert resp.status_code == 200 names = {s["name"] for s in resp.json()} # Alice sees hr_skill (A) + eng_skill (B) + global_skill. assert names == {"hr_skill", "eng_skill", "global_skill"} def test_admin_sees_all_resources(self, app: FastAPI, client: TestClient, dept_setup: dict): """Admin user sees all resources regardless of department bindings.""" _set_caller( app, user_id="admin-1", department_ids=[], is_admin=True, ) # Admin sees all skills. resp = client.get("/api/v1/skills") assert resp.status_code == 200 names = {s["name"] for s in resp.json()} assert names == {"hr_skill", "eng_skill", "global_skill"} # Admin sees all KB sources. resp = client.get("/api/v1/kb-management/sources") assert resp.status_code == 200 ids = {s["id"] for s in resp.json()["sources"]} assert ids == { dept_setup["hr_kb_id"], dept_setup["eng_kb_id"], dept_setup["global_kb_id"], } def test_user_removed_from_department_loses_access( self, app: FastAPI, client: TestClient, tmp_auth_db: Path, dept_setup: dict, ): """User removed from dept A can no longer see dept A's skills.""" user_id = "user-removal" dept_a = dept_setup["dept_a"] # Initially assign to dept A. _assign_user_to_department(tmp_auth_db, user_id, dept_a) _set_caller(app, user_id=user_id, department_ids=[dept_a]) resp = client.get("/api/v1/skills") names = {s["name"] for s in resp.json()} assert "hr_skill" in names # Remove from dept A — simulate the context change. _remove_user_from_department(tmp_auth_db, user_id, dept_a) _set_caller(app, user_id=user_id, department_ids=[]) resp = client.get("/api/v1/skills") names = {s["name"] for s in resp.json()} assert "hr_skill" not in names assert "global_skill" in names def test_disabled_department_excluded( self, app: FastAPI, client: TestClient, tmp_auth_db: Path, dept_setup: dict, ): """Disabled department's resources are not visible to its users. The ``_fetch_user_department_ids`` helper in :mod:`agentkit.server.admin.context` filters out disabled departments (``is_active=0``). We simulate this by disabling dept A in the DB and updating the caller's context to reflect the now-empty department list. """ user_id = "user-disabled-dept" dept_a = dept_setup["dept_a"] # Initially assign to dept A and verify access. _assign_user_to_department(tmp_auth_db, user_id, dept_a) _set_caller(app, user_id=user_id, department_ids=[dept_a]) resp = client.get("/api/v1/skills") names = {s["name"] for s in resp.json()} assert "hr_skill" in names # Disable dept A in the DB. _disable_department(tmp_auth_db, dept_a) # Simulate the context change: the next request's # ``get_department_context`` would re-query user_departments # and find no *active* departments. _set_caller(app, user_id=user_id, department_ids=[]) resp = client.get("/api/v1/skills") names = {s["name"] for s in resp.json()} assert "hr_skill" not in names assert "global_skill" in names def test_api_key_client_sees_only_global( self, app: FastAPI, client: TestClient, dept_setup: dict ): """API key client (no user_id) sees only global resources.""" # API-key client → user_id=None, department_ids=[], is_admin=False. _set_caller(app, user_id=None, department_ids=[], is_admin=False) # Skills: only global_skill. resp = client.get("/api/v1/skills") assert resp.status_code == 200 names = {s["name"] for s in resp.json()} assert names == {"global_skill"} # KB sources: only global_kb. resp = client.get("/api/v1/kb-management/sources") assert resp.status_code == 200 ids = {s["id"] for s in resp.json()["sources"]} assert ids == {dept_setup["global_kb_id"]} # --------------------------------------------------------------------------- # Non-admin access tests # --------------------------------------------------------------------------- class TestNonAdminAccess: """Non-admin user gets 403 on all admin endpoints.""" def test_non_admin_cannot_access_admin_endpoints(self, app: FastAPI, tmp_auth_db: Path): """Non-admin user gets 403 on a representative sample of admin endpoints.""" # Override _require_admin to raise 403 (simulating a non-admin caller). app.dependency_overrides[admin_routes_module._require_admin] = _raise_forbidden client = TestClient(app) # Representative sample of admin endpoints across all resource types. endpoints = [ ("GET", "/api/v1/admin/departments"), ("POST", "/api/v1/admin/departments"), ("GET", "/api/v1/admin/users"), ("POST", "/api/v1/admin/users"), ("GET", "/api/v1/admin/llm/providers"), ("POST", "/api/v1/admin/llm/providers"), ("GET", "/api/v1/admin/usage/summary"), ("GET", "/api/v1/admin/usage/timeseries"), ("GET", "/api/v1/admin/usage/by-model"), ("GET", "/api/v1/admin/usage/top-users"), ("GET", "/api/v1/admin/usage/export"), ] for method, path in endpoints: if method == "GET": resp = client.get(path) elif method == "POST": # Use a minimal body where required; we expect 403 before # body validation runs. body: dict[str, Any] | None = {} if path == "/api/v1/admin/departments": body = {"name": "X"} elif path == "/api/v1/admin/users": body = { "username": "x", "email": "x@x.com", "password": "Pw123!", } elif path == "/api/v1/admin/llm/providers": body = { "name": "x", "type": "openai", "api_key": "sk-x", } resp = client.post(path, json=body) assert resp.status_code == 403, ( f"{method} {path} returned {resp.status_code}, expected 403" )