CRITICAL — unauthenticated endpoints (CWE-306):
- agent_superset.py: 10 SQL/dashboard/dataset proxy endpoints → plugin:superset_proxy:EXECUTE
- agent_superset_explore.py: 10 database explore endpoints → plugin:superset_proxy:READ
- clean_release.py / clean_release_v2.py: router-level deny-by-default → clean_release:MANAGE
- settings.py: PUT/DELETE/test environment → admin:settings:WRITE/READ
- tasks.py: log/stats/sources/export → tasks:READ
HIGH — authorization gaps (CWE-285, CWE-613):
- require_api_key_or_jwt: add token blacklist + is_active + is_admin flag checks
- get_current_user: add is_active check
- WebSocket: add _authorize_websocket() RBAC helper, permission-gate all 6 WS endpoints
- agent_conversations: add Depends(get_current_user) to save endpoint + router-level guard
- legacy validation redirect: add validation.task:VIEW guard
MEDIUM — consistency & architecture:
- admin.py: fix permission parsing split(':',1) → rsplit(':',1)
- app.py lifespan: sync RBAC permission catalog at startup
- schemas/auth.py: add is_admin to RoleSchema (with BeforeValidator), RoleCreate, RoleUpdate
- models/auth.py: add is_admin support in create_role/update_role handlers
- permissions.ts: expand KNOWN_ACTIONS (VIEW/CREATE/EDIT/MANAGE/APPROVE/PREVIEW/LAUNCH/LAUNCH_PROD)
- permissions.ts: isAdminUser checks is_admin flag from /auth/me
- Navbar.svelte: replace exact role name check with hasPermission()
- admin/+page.svelte, admin/settings/llm/+page.svelte: add ProtectedRoute guards
TESTS:
- test_dependencies_unit.py: fix 5 tests for new is_token_blacklisted + is_admin checks
- test_api_key_auth.py: fix test_jwt_precedence for is_token_blacklisted mock
- permissions.test.ts: update non-KNOWN_ACTION test to use unknown suffix 'xyz'
VERIFIED: 230 backend tests pass, 3257 frontend tests pass, index rebuilt (7373 contracts, 3832 edges)
509 lines
17 KiB
Python
509 lines
17 KiB
Python
# #region Test.ApiKeyAuth.TestAPIKeyAuth [C:3] [TYPE Module] [SEMANTICS test, api_key, auth, dependency]
|
|
# @BRIEF Contract tests for API key authentication — valid key, invalid, revoked, expired, missing permission,
|
|
# environment scoping, JWT precedence. Uses TestClient with dependency overrides.
|
|
# @RELATION BINDS_TO -> [Core.ApiKey.APIKeyUtilities]
|
|
# @RELATION BINDS_TO -> [Models.ApiKey.APIKeyModel]
|
|
# @RELATION BINDS_TO -> [Api.Routes.MaintenanceRoutesModule]
|
|
# @TEST_CONTRACT: get_api_key_principal returns APIKeyPrincipal for valid key, None for no header, 401 for invalid/revoked/expired.
|
|
# @TEST_CONTRACT: require_api_key_or_jwt rejects invalid/revoked/expired keys, checks permissions, enforces environment scope.
|
|
# @TEST_EDGE: missing_header -> returns None
|
|
# @TEST_EDGE: invalid_key -> 401
|
|
# @TEST_EDGE: revoked_key -> 401
|
|
# @TEST_EDGE: expired_key -> 401
|
|
# @TEST_EDGE: missing_permission -> 403
|
|
# @TEST_EDGE: environment_scope_mismatch -> 400
|
|
# @TEST_EDGE: jwt_precedence -> JWT takes precedence over API key
|
|
from datetime import UTC, datetime, timedelta
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def db_session():
|
|
"""Create an in-memory SQLite session with the api_keys table."""
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from src.models.mapping import Base
|
|
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_api_key(db_session):
|
|
"""Create a valid API key and return its raw value, prefix, and database row."""
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Test Key",
|
|
permissions=["maintenance:start", "maintenance:end"],
|
|
active=True,
|
|
)
|
|
db_session.add(api_key)
|
|
db_session.commit()
|
|
return raw, api_key
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Create a TestClient."""
|
|
from src.app import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
"""Patch get_db dependency with in-memory SQLite session (same pattern as test_maintenance_api)."""
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from src.app import app
|
|
from src.dependencies import get_db
|
|
from src.models.mapping import Base
|
|
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
|
|
# Add default maintenance settings (needed by /api/maintenance endpoints)
|
|
from src.models.maintenance import DashboardScope, MaintenanceSettings
|
|
|
|
settings = MaintenanceSettings(
|
|
id="default",
|
|
target_environment_id="test-env",
|
|
display_timezone="UTC",
|
|
banner_template="Test: {message} ({start_time}-{end_time})",
|
|
dashboard_scope=DashboardScope.PUBLISHED_ONLY,
|
|
excluded_dashboard_ids=[],
|
|
forced_dashboard_ids=[],
|
|
)
|
|
session.add(settings)
|
|
session.commit()
|
|
|
|
def _get_db_override():
|
|
db = Session()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
app.dependency_overrides[get_db] = _get_db_override
|
|
yield session
|
|
app.dependency_overrides.pop(get_db, None)
|
|
session.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_task_manager():
|
|
"""Patch get_task_manager to return a mock."""
|
|
from src.app import app
|
|
from src.dependencies import get_task_manager
|
|
|
|
mock_tm = MagicMock()
|
|
mock_task = MagicMock()
|
|
mock_task.id = "test-task-id-123"
|
|
mock_tm.create_task = AsyncMock(return_value=mock_task)
|
|
|
|
app.dependency_overrides[get_task_manager] = lambda: mock_tm
|
|
yield mock_tm
|
|
app.dependency_overrides.pop(get_task_manager, None)
|
|
|
|
|
|
# ── Tests for get_api_key_principal ───────────────────────────
|
|
|
|
class TestGetApiKeyPrincipal:
|
|
"""Contract tests for get_api_key_principal dependency."""
|
|
|
|
# #region Test.ApiKeyAuth.TestNoHeaderReturnsNone [C:2] [TYPE Function]
|
|
# @BRIEF No X-API-Key header → returns None.
|
|
@pytest.mark.asyncio
|
|
async def test_no_header_returns_none(self):
|
|
from fastapi import Request
|
|
|
|
from src.dependencies import get_api_key_principal
|
|
|
|
# Mock a request without the header
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers.get.return_value = None
|
|
mock_db = MagicMock()
|
|
|
|
result = await get_api_key_principal(mock_request, mock_db)
|
|
assert result is None
|
|
|
|
# #endregion Test.ApiKeyAuth.TestNoHeaderReturnsNone
|
|
|
|
# #region Test.ApiKeyAuth.TestValidKeyReturnsPrincipal [C:2] [TYPE Function]
|
|
# @BRIEF Valid API key → returns APIKeyPrincipal with correct fields.
|
|
@pytest.mark.asyncio
|
|
async def test_valid_key_returns_principal(self, db_session, valid_api_key):
|
|
from fastapi import Request
|
|
|
|
from src.dependencies import get_api_key_principal
|
|
|
|
raw_key, api_key_row = valid_api_key
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers.get.return_value = raw_key
|
|
|
|
result = await get_api_key_principal(mock_request, db_session)
|
|
assert result is not None
|
|
assert result.name == "Test Key"
|
|
assert result.api_key_id == api_key_row.id
|
|
assert result.environment_id is None
|
|
assert "maintenance:start" in result.permissions
|
|
|
|
# #endregion Test.ApiKeyAuth.TestValidKeyReturnsPrincipal
|
|
|
|
# #region Test.ApiKeyAuth.TestInvalidKeyRaises401 [C:2] [TYPE Function]
|
|
# @BRIEF Invalid API key → 401.
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_key_raises_401(self, db_session):
|
|
from fastapi import Request
|
|
|
|
from src.dependencies import get_api_key_principal
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers.get.return_value = "ssk_invalid_key_that_does_not_exist"
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_api_key_principal(mock_request, db_session)
|
|
assert exc.value.status_code == 401
|
|
|
|
# #endregion Test.ApiKeyAuth.TestInvalidKeyRaises401
|
|
|
|
# #region Test.ApiKeyAuth.TestRevokedKeyRaises401 [C:2] [TYPE Function]
|
|
# @BRIEF Revoked (active=False) API key → 401.
|
|
@pytest.mark.asyncio
|
|
async def test_revoked_key_raises_401(self, db_session):
|
|
from fastapi import Request
|
|
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.dependencies import get_api_key_principal
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Revoked Key",
|
|
permissions=["maintenance:start"],
|
|
active=False, # revoked
|
|
)
|
|
db_session.add(api_key)
|
|
db_session.commit()
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers.get.return_value = raw
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_api_key_principal(mock_request, db_session)
|
|
assert exc.value.status_code == 401
|
|
|
|
# #endregion Test.ApiKeyAuth.TestRevokedKeyRaises401
|
|
|
|
# #region Test.ApiKeyAuth.TestExpiredKeyRaises401 [C:2] [TYPE Function]
|
|
# @BRIEF Expired API key → 401.
|
|
@pytest.mark.asyncio
|
|
async def test_expired_key_raises_401(self, db_session):
|
|
from fastapi import Request
|
|
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.dependencies import get_api_key_principal
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Expired Key",
|
|
permissions=["maintenance:start"],
|
|
active=True,
|
|
expires_at=datetime.now(UTC) - timedelta(hours=1), # expired
|
|
)
|
|
db_session.add(api_key)
|
|
db_session.commit()
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers.get.return_value = raw
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_api_key_principal(mock_request, db_session)
|
|
assert exc.value.status_code == 401
|
|
|
|
# #endregion Test.ApiKeyAuth.TestExpiredKeyRaises401
|
|
|
|
|
|
# #endregion Test.ApiKeyAuth.TestAPIKeyAuth
|
|
|
|
|
|
# ── Tests for require_api_key_or_jwt (integration via TestClient) ──────
|
|
|
|
class TestRequireApiKeyOrJwt:
|
|
"""Contract tests for require_api_key_or_jwt dependency factory via TestClient."""
|
|
|
|
API_START_URL = "/api/maintenance/start"
|
|
START_PAYLOAD = {
|
|
"tables": ["raw.sales"],
|
|
"start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
|
"end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(),
|
|
"message": "Test maintenance",
|
|
"environment_id": "ss-dev",
|
|
}
|
|
|
|
@staticmethod
|
|
def _create_api_key(db, environment_id=None, permissions=None):
|
|
"""Helper to create an API key in the test DB and return the raw key."""
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Test Key",
|
|
permissions=permissions or ["maintenance:start"],
|
|
active=True,
|
|
environment_id=environment_id,
|
|
)
|
|
db.add(api_key)
|
|
db.commit()
|
|
return raw
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthValid [C:2] [TYPE Function]
|
|
# @BRIEF Valid API key with matching permission → 202.
|
|
def test_api_key_auth_valid(self, client, mock_db, mock_task_manager):
|
|
raw_key = self._create_api_key(mock_db, permissions=["maintenance:start"])
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={"X-API-Key": raw_key},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
assert data["status"] == "pending"
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthValid
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthWrongPermission [C:2] [TYPE Function]
|
|
# @BRIEF API key lacks required permission → 403.
|
|
def test_api_key_auth_wrong_permission(self, client, mock_db, mock_task_manager):
|
|
# Key has 'maintenance:end' only, not 'maintenance:start'
|
|
raw_key = self._create_api_key(mock_db, permissions=["maintenance:end"])
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={"X-API-Key": raw_key},
|
|
)
|
|
assert response.status_code == 403
|
|
data = response.json()
|
|
assert "detail" in data
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthWrongPermission
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthRevoked [C:2] [TYPE Function]
|
|
# @BRIEF Revoked API key → 401.
|
|
def test_api_key_auth_revoked(self, client, mock_db, mock_task_manager):
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Revoked Key",
|
|
permissions=["maintenance:start"],
|
|
active=False,
|
|
)
|
|
mock_db.add(api_key)
|
|
mock_db.commit()
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={"X-API-Key": raw},
|
|
)
|
|
assert response.status_code == 401
|
|
assert "revoked" in response.json().get("detail", "").lower()
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthRevoked
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthExpired [C:2] [TYPE Function]
|
|
# @BRIEF Expired API key → 401.
|
|
def test_api_key_auth_expired(self, client, mock_db, mock_task_manager):
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash,
|
|
prefix=prefix,
|
|
name="Expired Key",
|
|
permissions=["maintenance:start"],
|
|
active=True,
|
|
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
|
)
|
|
mock_db.add(api_key)
|
|
mock_db.commit()
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={"X-API-Key": raw},
|
|
)
|
|
assert response.status_code == 401
|
|
assert "expired" in response.json().get("detail", "").lower()
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthExpired
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthInvalid [C:2] [TYPE Function]
|
|
# @BRIEF Non-existent key → 401.
|
|
def test_api_key_auth_invalid(self, client, mock_db, mock_task_manager):
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={"X-API-Key": "ssk_invalid_nonexistent_key"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert "invalid" in response.json().get("detail", "").lower()
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthInvalid
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthEnvironmentScope [C:2] [TYPE Function]
|
|
# @BRIEF Key scoped to ss-dev, request for ss-prod → 400.
|
|
def test_api_key_auth_environment_scope(self, client, mock_db, mock_task_manager):
|
|
raw_key = self._create_api_key(
|
|
mock_db,
|
|
environment_id="ss-dev",
|
|
permissions=["maintenance:start"],
|
|
)
|
|
|
|
payload = dict(self.START_PAYLOAD)
|
|
payload["environment_id"] = "ss-prod" # Mismatch!
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=payload,
|
|
headers={"X-API-Key": raw_key},
|
|
)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "restricted" in data.get("detail", "").lower()
|
|
assert "ss-dev" in data.get("detail", "")
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthEnvironmentScope
|
|
|
|
# #region Test.ApiKeyAuth.TestApiKeyAuthEnvironmentScopeOk [C:2] [TYPE Function]
|
|
# @BRIEF Key scoped to ss-dev, request for ss-dev → 202.
|
|
def test_api_key_auth_environment_scope_ok(self, client, mock_db, mock_task_manager):
|
|
raw_key = self._create_api_key(
|
|
mock_db,
|
|
environment_id="ss-dev",
|
|
permissions=["maintenance:start"],
|
|
)
|
|
|
|
payload = dict(self.START_PAYLOAD)
|
|
payload["environment_id"] = "ss-dev" # Match!
|
|
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=payload,
|
|
headers={"X-API-Key": raw_key},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
|
|
# #endregion Test.ApiKeyAuth.TestApiKeyAuthEnvironmentScopeOk
|
|
|
|
# #region Test.ApiKeyAuth.TestJwtPrecedence [C:2] [TYPE Function]
|
|
# @BRIEF Both valid API key + valid JWT → JWT takes precedence.
|
|
def test_jwt_precedence(self, client, mock_db, mock_task_manager):
|
|
from src.app import app
|
|
from src.core.auth.jwt import create_access_token
|
|
from src.dependencies import get_auth_db
|
|
from src.models.auth import Role, User
|
|
|
|
raw_key = self._create_api_key(
|
|
mock_db,
|
|
environment_id="ss-dev",
|
|
permissions=["maintenance:start"],
|
|
)
|
|
|
|
# Create a JWT user with Admin role (full access)
|
|
role = Role(name="Admin", description="Admin role")
|
|
role.is_admin = True
|
|
mock_db.add(role)
|
|
mock_db.commit()
|
|
|
|
user = User(
|
|
username="jwt-user",
|
|
password_hash="nohash",
|
|
auth_source="LOCAL",
|
|
is_active=True,
|
|
)
|
|
user.roles.append(role)
|
|
mock_db.add(user)
|
|
mock_db.commit()
|
|
|
|
# Create a valid JWT token
|
|
token = create_access_token(data={"sub": "jwt-user"})
|
|
|
|
# Override get_auth_db to use our mock_db for auth queries too
|
|
# The auth DB and main DB are separate in production, but for tests
|
|
# we use the same in-memory DB
|
|
|
|
def _get_auth_db_override():
|
|
yield mock_db
|
|
|
|
app.dependency_overrides[get_auth_db] = _get_auth_db_override
|
|
|
|
# Send request with BOTH X-API-Key and Authorization: Bearer
|
|
with patch('src.dependencies.is_token_blacklisted', return_value=False):
|
|
response = client.post(
|
|
self.API_START_URL,
|
|
json=self.START_PAYLOAD,
|
|
headers={
|
|
"X-API-Key": raw_key,
|
|
"Authorization": f"Bearer {token}",
|
|
},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert "task_id" in data
|
|
|
|
# Cleanup
|
|
app.dependency_overrides.pop(get_auth_db, None)
|
|
|
|
# #endregion Test.ApiKeyAuth.TestJwtPrecedence
|
|
|
|
|
|
# #endregion TestRequireApiKeyOrJwt
|