415 lines
14 KiB
Python
415 lines
14 KiB
Python
"""M16 credential and authentication adversarial tests.
|
|
|
|
Every question here is the one an attacker asks: what happens with no credential, a malformed one,
|
|
a revoked one, a rotated one, one scoped to a different capability, one belonging to a different
|
|
project, a node credential on an operator route, a capability credential on a lifecycle route.
|
|
|
|
The expected answer is always the same shape — refused, with the same error code, revealing
|
|
nothing about why.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from modelforge_api.persistence.models import (
|
|
Base,
|
|
Capability,
|
|
CapabilityContract,
|
|
Project,
|
|
ProjectBinding,
|
|
ServiceClient,
|
|
ServiceCredential,
|
|
)
|
|
from modelforge_api.services.manifest_registry import ManifestRegistry
|
|
from modelforge_api.services.serving import ServingError, ServingService
|
|
from modelforge_api.services.transient_payloads import MemoryPayloadStore
|
|
from modelforge_api.settings import Settings
|
|
|
|
|
|
@pytest.fixture
|
|
def session() -> Session:
|
|
engine = create_engine(
|
|
"sqlite+pysqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
with Session(engine) as value:
|
|
yield value
|
|
|
|
|
|
def settings() -> Settings:
|
|
return Settings(_env_file=None, operator_api_key=SecretStr("m16-operator-key"))
|
|
|
|
|
|
def service(session: Session) -> ServingService:
|
|
return ServingService(session, settings(), ManifestRegistry(), MemoryPayloadStore())
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def make_contract(session: Session, key: str, version: int = 1) -> CapabilityContract:
|
|
capability = session.scalar(select(Capability).where(Capability.key == key))
|
|
if capability is None:
|
|
capability = Capability(key=key, description=key)
|
|
session.add(capability)
|
|
session.flush()
|
|
contract = CapabilityContract(
|
|
capability_id=capability.id,
|
|
version=version,
|
|
input_schema={},
|
|
output_schema={},
|
|
contract={},
|
|
upgrade_class="behavioral",
|
|
)
|
|
session.add(contract)
|
|
session.flush()
|
|
return contract
|
|
|
|
|
|
def make_client(
|
|
session: Session,
|
|
*,
|
|
name: str,
|
|
capabilities: list[str],
|
|
binding: ProjectBinding | None = None,
|
|
status: str = "active",
|
|
) -> tuple[ServiceClient, str]:
|
|
client = ServiceClient(
|
|
name=name,
|
|
status=status,
|
|
allowed_capabilities=capabilities,
|
|
requests_per_minute=600,
|
|
max_concurrent_requests=32,
|
|
workload_priority="production",
|
|
integration_environment="LAB",
|
|
purpose="M16 adversarial credential test",
|
|
project_binding_id=binding.id if binding else None,
|
|
)
|
|
session.add(client)
|
|
session.flush()
|
|
secret = f"mfsvc_{uuid.uuid4().hex}{uuid.uuid4().hex}"
|
|
session.add(
|
|
ServiceCredential(
|
|
service_client_id=client.id,
|
|
secret_hash=hashlib.sha256(secret.encode()).hexdigest(),
|
|
secret_prefix=secret[:12],
|
|
)
|
|
)
|
|
session.commit()
|
|
return client, secret
|
|
|
|
|
|
def make_binding(session: Session, contract: CapabilityContract, project_key: str) -> ProjectBinding:
|
|
project = Project(key=project_key, name=project_key, description=project_key)
|
|
session.add(project)
|
|
session.flush()
|
|
binding = ProjectBinding(
|
|
project_id=project.id,
|
|
capability_contract_id=contract.id,
|
|
channel="stable",
|
|
priority="production",
|
|
)
|
|
session.add(binding)
|
|
session.commit()
|
|
return binding
|
|
|
|
|
|
# --------------------------------------------------------------------- missing and malformed
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"authorization",
|
|
[
|
|
None,
|
|
"",
|
|
" ",
|
|
"Bearer",
|
|
"Bearer ",
|
|
"Bearer ",
|
|
"Basic dXNlcjpwYXNz",
|
|
"bearer lowercase-scheme",
|
|
"mfsvc_no_scheme_at_all",
|
|
"Bearer \x00nullbyte",
|
|
"Bearer " + "a" * 10000,
|
|
"Bearer ../../etc/passwd",
|
|
"Bearer ' OR '1'='1",
|
|
"Bearer <script>alert(1)</script>",
|
|
"Bearer \n\rinjected: header",
|
|
],
|
|
)
|
|
def test_a_missing_or_malformed_credential_is_refused(
|
|
session: Session, authorization: str | None
|
|
) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(authorization, "rag.embedding@1")
|
|
assert error.value.status_code == 401
|
|
assert error.value.code == "CAPABILITY_NOT_AUTHORIZED"
|
|
|
|
|
|
def test_every_invalid_credential_returns_the_same_code_and_status(session: Session) -> None:
|
|
"""A different message for 'unknown' and 'revoked' is a token-enumeration oracle."""
|
|
|
|
make_contract(session, "rag.embedding")
|
|
_client, secret = make_client(
|
|
session, name="probe", capabilities=["rag.embedding@1"]
|
|
)
|
|
revoked_client, revoked_secret = make_client(
|
|
session, name="revoked", capabilities=["rag.embedding@1"]
|
|
)
|
|
credential = session.scalar(
|
|
select(ServiceCredential).where(ServiceCredential.service_client_id == revoked_client.id)
|
|
)
|
|
assert credential is not None
|
|
credential.revoked_at = _now()
|
|
session.commit()
|
|
|
|
outcomes = set()
|
|
for authorization in (
|
|
"Bearer unknown-credential-value",
|
|
f"Bearer {revoked_secret}",
|
|
f"Bearer {secret[:-1]}x",
|
|
"Bearer ",
|
|
):
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(authorization, "rag.embedding@1")
|
|
outcomes.add((error.value.status_code, error.value.code))
|
|
assert outcomes == {(401, "CAPABILITY_NOT_AUTHORIZED")}
|
|
|
|
|
|
def test_a_valid_credential_still_works_after_a_burst_of_invalid_ones(session: Session) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
_client, secret = make_client(session, name="survivor", capabilities=["rag.embedding@1"])
|
|
|
|
for index in range(50):
|
|
with pytest.raises(ServingError):
|
|
service(session).authenticate(f"Bearer invalid-{index}", "rag.embedding@1")
|
|
|
|
assert service(session).authenticate(f"Bearer {secret}", "rag.embedding@1").name == "survivor"
|
|
|
|
|
|
# --------------------------------------------------------------------- scope isolation
|
|
|
|
|
|
def test_a_vision_client_cannot_invoke_speech_transcription(session: Session) -> None:
|
|
"""The ExampleVision Vision client must not reach ASR because both happen to be LAB."""
|
|
|
|
make_contract(session, "vision.embedding")
|
|
make_contract(session, "speech.transcription")
|
|
_client, secret = make_client(
|
|
session, name="examplevision-vision", capabilities=["vision.embedding@1"]
|
|
)
|
|
|
|
assert service(session).authenticate(f"Bearer {secret}", "vision.embedding@1")
|
|
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "speech.transcription@1")
|
|
assert error.value.status_code == 403
|
|
assert error.value.code == "CAPABILITY_NOT_AUTHORIZED"
|
|
|
|
|
|
def test_a_speech_client_cannot_invoke_vision(session: Session) -> None:
|
|
make_contract(session, "vision.embedding")
|
|
make_contract(session, "speech.transcription")
|
|
_client, secret = make_client(
|
|
session, name="asr-consumer", capabilities=["speech.transcription@1"]
|
|
)
|
|
|
|
assert service(session).authenticate(f"Bearer {secret}", "speech.transcription@1")
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "vision.embedding@1")
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
def test_a_client_cannot_reach_a_different_contract_version(session: Session) -> None:
|
|
make_contract(session, "rag.embedding", version=1)
|
|
make_contract(session, "rag.embedding", version=2)
|
|
_client, secret = make_client(session, name="v1-only", capabilities=["rag.embedding@1"])
|
|
|
|
assert service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "rag.embedding@2")
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"capability",
|
|
[
|
|
"admin.recovery",
|
|
"admin.lifecycle",
|
|
"node.publish",
|
|
"node.enroll",
|
|
"operator",
|
|
"rag.embedding@1 rag.reranking@1",
|
|
"RAG.EMBEDDING@1",
|
|
"rag.embedding@1\u200b",
|
|
],
|
|
)
|
|
def test_a_client_cannot_escalate_by_naming_another_scope(
|
|
session: Session, capability: str
|
|
) -> None:
|
|
"""Scope matching is exact; case, whitespace and zero-width tricks do not widen it."""
|
|
|
|
make_contract(session, "rag.embedding")
|
|
_client, secret = make_client(session, name="scoped", capabilities=["rag.embedding@1"])
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", capability)
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
# --------------------------------------------------------------------- cross project
|
|
|
|
|
|
def test_one_project_credential_cannot_serve_another_projects_binding(session: Session) -> None:
|
|
contract_a = make_contract(session, "rag.embedding")
|
|
contract_b = make_contract(session, "vision.embedding")
|
|
binding_a = make_binding(session, contract_a, "examplerag")
|
|
make_binding(session, contract_b, "examplevision")
|
|
|
|
_client, secret = make_client(
|
|
session,
|
|
name="examplerag-client",
|
|
capabilities=["rag.embedding@1", "vision.embedding@1"],
|
|
binding=binding_a,
|
|
)
|
|
|
|
assert service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "vision.embedding@1")
|
|
assert error.value.status_code == 403
|
|
assert error.value.code == "PROJECT_CAPABILITY_NOT_BOUND"
|
|
|
|
|
|
def test_a_deprecated_project_binding_stops_serving(session: Session) -> None:
|
|
contract = make_contract(session, "rag.embedding")
|
|
binding = make_binding(session, contract, "examplerag")
|
|
_client, secret = make_client(
|
|
session, name="bound", capabilities=["rag.embedding@1"], binding=binding
|
|
)
|
|
assert service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
|
|
binding.deprecated_at = _now()
|
|
session.commit()
|
|
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
assert error.value.status_code == 403
|
|
assert error.value.code == "PROJECT_CAPABILITY_NOT_BOUND"
|
|
|
|
|
|
# --------------------------------------------------------------------- revocation and rotation
|
|
|
|
|
|
def test_a_revoked_credential_is_refused_immediately_and_permanently(session: Session) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
client, secret = make_client(session, name="revoked", capabilities=["rag.embedding@1"])
|
|
assert service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
|
|
credential = session.scalar(
|
|
select(ServiceCredential).where(ServiceCredential.service_client_id == client.id)
|
|
)
|
|
assert credential is not None
|
|
credential.revoked_at = _now()
|
|
session.commit()
|
|
|
|
for _ in range(5):
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
assert error.value.status_code == 401
|
|
|
|
|
|
def test_an_expired_credential_is_refused(session: Session) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
client, secret = make_client(session, name="expiring", capabilities=["rag.embedding@1"])
|
|
credential = session.scalar(
|
|
select(ServiceCredential).where(ServiceCredential.service_client_id == client.id)
|
|
)
|
|
assert credential is not None
|
|
credential.expires_at = _now() - timedelta(seconds=1)
|
|
session.commit()
|
|
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
assert error.value.status_code == 401
|
|
|
|
|
|
def test_rotation_leaves_exactly_one_usable_secret(session: Session) -> None:
|
|
"""Two simultaneously valid secrets is an unbounded window, not a graceful cutover."""
|
|
|
|
make_contract(session, "rag.embedding")
|
|
client, old_secret = make_client(session, name="rotating", capabilities=["rag.embedding@1"])
|
|
assert service(session).authenticate(f"Bearer {old_secret}", "rag.embedding@1")
|
|
|
|
old = session.scalar(
|
|
select(ServiceCredential).where(ServiceCredential.service_client_id == client.id)
|
|
)
|
|
assert old is not None
|
|
old.revoked_at = _now()
|
|
new_secret = f"mfsvc_{uuid.uuid4().hex}{uuid.uuid4().hex}"
|
|
session.add(
|
|
ServiceCredential(
|
|
service_client_id=client.id,
|
|
secret_hash=hashlib.sha256(new_secret.encode()).hexdigest(),
|
|
secret_prefix=new_secret[:12],
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
with pytest.raises(ServingError):
|
|
service(session).authenticate(f"Bearer {old_secret}", "rag.embedding@1")
|
|
assert service(session).authenticate(f"Bearer {new_secret}", "rag.embedding@1").id == client.id
|
|
|
|
usable = session.scalars(
|
|
select(ServiceCredential).where(
|
|
ServiceCredential.service_client_id == client.id,
|
|
ServiceCredential.revoked_at.is_(None),
|
|
)
|
|
).all()
|
|
assert len(usable) == 1
|
|
|
|
|
|
def test_a_disabled_client_cannot_serve_even_with_a_valid_secret(session: Session) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
client, secret = make_client(session, name="disabled", capabilities=["rag.embedding@1"])
|
|
client.status = "disabled"
|
|
client.disabled_at = _now()
|
|
session.commit()
|
|
|
|
with pytest.raises(ServingError) as error:
|
|
service(session).authenticate(f"Bearer {secret}", "rag.embedding@1")
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
# --------------------------------------------------------------------- storage boundary
|
|
|
|
|
|
def test_only_a_hash_of_a_credential_is_ever_stored(session: Session) -> None:
|
|
make_contract(session, "rag.embedding")
|
|
client, secret = make_client(session, name="hashed", capabilities=["rag.embedding@1"])
|
|
credential = session.scalar(
|
|
select(ServiceCredential).where(ServiceCredential.service_client_id == client.id)
|
|
)
|
|
assert credential is not None
|
|
assert credential.secret_hash == hashlib.sha256(secret.encode()).hexdigest()
|
|
assert secret not in credential.secret_hash
|
|
assert len(credential.secret_prefix) <= 16
|
|
assert secret[16:] not in credential.secret_prefix
|
|
|
|
columns = {column.name for column in ServiceCredential.__table__.columns}
|
|
assert "secret" not in columns
|
|
assert "plaintext" not in columns
|
|
assert "password" not in columns
|