Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@v6
with:
version: "0.8.22"
Expand All @@ -20,6 +23,8 @@ jobs:
- run: make db-up
- name: Run complete checks including the M0 security gate
id: complete-checks
env:
OPENAPI_BASELINE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: make check
- name: Retain M0 security gate evidence
if: always()
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration check
.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration openapi-generate openapi-check openapi-breaking-check check

install:
uv sync --frozen
Expand Down Expand Up @@ -37,4 +37,13 @@ db-reset:
integration:
./scripts/database_harness.sh integration

check: build lint typecheck test catalog smoke integration security-gate
openapi-generate:
uv run python scripts/freeze_openapi.py generate

openapi-check:
uv run python scripts/freeze_openapi.py check $(if $(OPENAPI_BASELINE_REF),--baseline-ref $(OPENAPI_BASELINE_REF),)

openapi-breaking-check:
uv run pytest -q tests/unit/test_openapi_v0_snapshot.py

check: build lint typecheck openapi-check test catalog smoke integration security-gate
110 changes: 98 additions & 12 deletions adapters/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from adapters.http.contracts import (
AcquireWire,
ApplicationForbiddenWire,
AuthenticationFailureWire,
ChannelEgressGrantWire,
CitationNotAvailableWire,
Expand All @@ -33,11 +34,13 @@
InvalidRequestWire,
ModelEgressGrantWire,
OpenCitationWire,
RateLimitedWire,
RequestNotAvailableWire,
ResolutionOutcomeWire,
ResolvedWire,
ResolveWire,
ServiceUnavailableWire,
resolution_outcome_public_document,
)
from adapters.http.membership_authority import (
MembershipAuthority,
Expand All @@ -48,6 +51,11 @@
OrganizationVerificationRejected,
RejectingOrganizationAuthority,
)
from adapters.http.route_policy import (
AllowAuthenticatedResolveRoutePolicy,
ResolveRouteDecision,
ResolveRoutePolicy,
)
from adapters.http.scope_authority import (
MissingTrustedScopeAuthority,
ScopeAuthority,
Expand Down Expand Up @@ -110,6 +118,7 @@
)
from engine.runtime.package_digest import QueryDigestKeyring
from engine.runtime.policy_epoch import PolicyEpochAuthorityUnavailable
from engine.runtime.release_lineage import ActiveReleaseUnavailable
from engine.runtime.scope_authority import InvalidTrustedScopeSnapshot

HEALTH_RESPONSE: Final = {
Expand All @@ -121,7 +130,12 @@
AUTHENTICATION_FAILED_RESPONSE: Final = {"code": "authentication_failed"}
INVALID_REQUEST_RESPONSE: Final = {"code": "invalid_request"}
SERVICE_UNAVAILABLE_RESPONSE: Final = {"code": "service_unavailable"}
RESOLVE_PATH: Final = "/v1/context:resolve"
APPLICATION_FORBIDDEN_RESPONSE: Final = {"code": "application_forbidden"}
RATE_LIMITED_RESPONSE: Final = {"code": "rate_limited"}
PUBLIC_API_VERSION: Final = "0.0.0"
PUBLIC_RESOLVE_PATH: Final = "/v0/resolve"
LEGACY_RESOLVE_PATH: Final = "/v1/context:resolve"
RESOLVE_PATHS: Final = frozenset({PUBLIC_RESOLVE_PATH, LEGACY_RESOLVE_PATH})


class TransportAuthenticationFailed(Exception):
Expand All @@ -132,6 +146,14 @@ class TrustedAuthorityUnavailable(Exception):
"""A required trusted authority failed without exposing identity detail."""


class ResolveApplicationForbidden(Exception):
"""The authenticated application is not allowed to use this route."""


class ResolveRateLimited(Exception):
"""The authenticated application exceeded a route-only resource policy."""


class InvalidRequestMediaType(Exception):
"""Resolve received a body outside its sole JSON media type."""

Expand Down Expand Up @@ -181,6 +203,7 @@ def create_app(
organization_authority: OrganizationAuthority | None = None,
membership_authority: MembershipAuthority | None = None,
scope_authority: ScopeAuthority | None = None,
route_policy: ResolveRoutePolicy | None = None,
runtime: Runtime | None = None,
query_digest_keyring: QueryDigestKeyring | None = None,
invocation_observer: Callable[[AuthenticatedInvocation], None] | None = None,
Expand Down Expand Up @@ -213,16 +236,17 @@ def create_app(
membership_authority or RejectingMembershipAuthority()
)
selected_scope_authority = scope_authority or MissingTrustedScopeAuthority()
selected_route_policy = route_policy or AllowAuthenticatedResolveRoutePolicy()
bearer = HTTPBearer(
scheme_name="ContextEngineBearer",
bearerFormat="opaque",
auto_error=False,
)
app = FastAPI(title="ContextEngine", version=BUILD_IDENTIFIER)
app = FastAPI(title="ContextEngine", version=PUBLIC_API_VERSION)
app.add_middleware(
ResolveBodyLimitMiddleware,
profile=transport_profile,
resolve_path=RESOLVE_PATH,
resolve_paths=RESOLVE_PATHS,
invalid_response=INVALID_REQUEST_RESPONSE,
)

Expand All @@ -245,6 +269,22 @@ async def trusted_authority_unavailable(
del request, error
return JSONResponse(SERVICE_UNAVAILABLE_RESPONSE, status_code=503)

@app.exception_handler(ResolveApplicationForbidden)
async def application_forbidden(
request: Request,
error: ResolveApplicationForbidden,
) -> JSONResponse:
del request, error
return JSONResponse(APPLICATION_FORBIDDEN_RESPONSE, status_code=403)

@app.exception_handler(ResolveRateLimited)
async def rate_limited(
request: Request,
error: ResolveRateLimited,
) -> JSONResponse:
del request, error
return JSONResponse(RATE_LIMITED_RESPONSE, status_code=429)

@app.exception_handler(InvalidRequestMediaType)
@app.exception_handler(InvalidJsonTransport)
async def invalid_media_type(
Expand Down Expand Up @@ -326,16 +366,47 @@ def verified_authentication(
raise TransportAuthenticationFailed
return context

def require_public_request_id(
authentication: Annotated[
VerifiedAuthenticationContext,
Depends(verified_authentication),
],
context_request_id: Annotated[
str,
Header(
alias="X-Context-Request-Id",
min_length=1,
max_length=transport_profile.max_correlation_id_characters,
pattern=r".*\S.*",
),
],
) -> None:
"""Require request-bound metadata on the frozen public carrier."""

del authentication, context_request_id

@app.get("/health", include_in_schema=False)
def health() -> dict[str, str]:
return HEALTH_RESPONSE.copy()

@app.post(
RESOLVE_PATH,
LEGACY_RESOLVE_PATH,
include_in_schema=False,
status_code=200,
response_model=ResolutionOutcomeWire,
response_model_by_alias=True,
dependencies=[Depends(require_closed_json_transport)],
)
@app.post(
PUBLIC_RESOLVE_PATH,
operation_id="resolveContextV0",
status_code=200,
response_model=ResolutionOutcomeWire,
response_model_by_alias=True,
dependencies=[
Depends(require_closed_json_transport),
Depends(require_public_request_id),
],
responses={
400: {
"model": InvalidRequestWire,
Expand All @@ -354,10 +425,18 @@ def health() -> dict[str, str]:
}
},
},
403: {
"model": ApplicationForbiddenWire,
"description": "The authenticated application is not allowed.",
},
422: {
"model": InvalidRequestWire,
"description": "The closed request schema rejected the body.",
},
429: {
"model": RateLimitedWire,
"description": "The application exceeded the route resource policy.",
},
503: {
"model": ServiceUnavailableWire,
"description": "A required trusted authority is unavailable.",
Expand Down Expand Up @@ -385,7 +464,7 @@ def resolve_context(
alias="X-Context-Delivery-Evidence-Ref",
min_length=1,
max_length=transport_profile.max_delivery_evidence_ref_characters,
pattern=r".*\S.*",
pattern=r"^\S+$",
),
] = None,
) -> JSONResponse:
Expand All @@ -394,6 +473,16 @@ def resolve_context(
runtime_request = _runtime_request_from_wire(body)
request_id = context_request_id or request_id_factory()
received_at = clock()
try:
route_decision = selected_route_policy.decide(authentication)
except Exception:
raise TrustedAuthorityUnavailable from None
if route_decision is ResolveRouteDecision.FORBID:
raise ResolveApplicationForbidden
if route_decision is ResolveRouteDecision.RATE_LIMIT:
raise ResolveRateLimited
if route_decision is not ResolveRouteDecision.ALLOW:
raise TrustedAuthorityUnavailable
private_binding = authentication.private_delivery_binding
if delivery_evidence_ref is None:
if private_binding is not None:
Expand Down Expand Up @@ -508,8 +597,7 @@ def resolve_context(
raise TransportAuthenticationFailed
redemption_session = (
current_membership_verification
.delivery_evidence_redemption_session
)
).delivery_evidence_redemption_session
if redemption_session is None:
raise TrustedAuthorityUnavailable
try:
Expand Down Expand Up @@ -590,11 +678,7 @@ def resolve_context(
if type(outcome) is Resolved and resolution_observer is not None:
resolution_observer(outcome)
return JSONResponse(
response.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
),
resolution_outcome_public_document(response),
status_code=200,
headers={
"Cache-Control": "no-store",
Expand All @@ -617,6 +701,8 @@ def resolve_context(
raise TrustedAuthorityUnavailable from None
except ScopeAuthorityUnavailable:
raise TrustedAuthorityUnavailable from None
except ActiveReleaseUnavailable:
raise TrustedAuthorityUnavailable from None
except InvalidTrustedScopeSnapshot:
raise TrustedAuthorityUnavailable from None

Expand Down
Loading
Loading