diff --git a/backend/infrahub/api/__init__.py b/backend/infrahub/api/__init__.py index ab1e0e104d..a71c71f5b8 100644 --- a/backend/infrahub/api/__init__.py +++ b/backend/infrahub/api/__init__.py @@ -1,11 +1,13 @@ -from typing import NoReturn +from __future__ import annotations -from fastapi import APIRouter +from typing import TYPE_CHECKING, NoReturn + +from fastapi import APIRouter, Depends from fastapi.openapi.docs import ( get_redoc_html, get_swagger_ui_html, ) -from starlette.responses import HTMLResponse +from starlette.responses import HTMLResponse # noqa: TC002 from infrahub.api import ( artifact, @@ -21,8 +23,12 @@ storage, transformation, ) +from infrahub.api.dependencies import get_current_user from infrahub.exceptions import ResourceNotFoundError +if TYPE_CHECKING: + from infrahub.auth import AccountSession + router = APIRouter(prefix="/api") router.include_router(artifact.router) @@ -40,7 +46,9 @@ @router.get("/docs", include_in_schema=False) -async def custom_swagger_ui_html() -> HTMLResponse: +async def custom_swagger_ui_html( + _: AccountSession = Depends(get_current_user), +) -> HTMLResponse: return get_swagger_ui_html( openapi_url="/api/openapi.json", title="Infrahub - Swagger UI", @@ -50,7 +58,7 @@ async def custom_swagger_ui_html() -> HTMLResponse: @router.get("/redoc", include_in_schema=False) -async def redoc_html() -> HTMLResponse: +async def redoc_html(_: AccountSession = Depends(get_current_user)) -> HTMLResponse: return get_redoc_html( openapi_url="/api/openapi.json", title="Infrahub - ReDoc", diff --git a/backend/infrahub/api/artifact.py b/backend/infrahub/api/artifact.py index 70c3e8f163..2d052aaf88 100644 --- a/backend/infrahub/api/artifact.py +++ b/backend/infrahub/api/artifact.py @@ -18,6 +18,7 @@ from infrahub.workflows.catalogue import REQUEST_ARTIFACT_DEFINITION_GENERATE if TYPE_CHECKING: + from infrahub.auth import AccountSession from infrahub.permissions import PermissionManager log = get_logger() @@ -37,7 +38,7 @@ async def get_artifact( artifact_id: str, db: InfrahubDatabase = Depends(get_db), branch_params: BranchParams = Depends(get_branch_params), - _: str = Depends(get_current_user), + _: AccountSession = Depends(get_current_user), ) -> Response: artifact = await registry.manager.get_one(db=db, id=artifact_id, branch=branch_params.branch, at=branch_params.at) if not artifact: diff --git a/backend/infrahub/api/auth.py b/backend/infrahub/api/auth.py index 5554419389..36714e96a3 100644 --- a/backend/infrahub/api/auth.py +++ b/backend/infrahub/api/auth.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from fastapi import APIRouter, Depends, Response from infrahub import config, models @@ -8,7 +12,9 @@ create_fresh_access_token, invalidate_refresh_token, ) -from infrahub.database import InfrahubDatabase + +if TYPE_CHECKING: + from infrahub.database import InfrahubDatabase router = APIRouter(prefix="/auth") diff --git a/backend/infrahub/api/diff/diff.py b/backend/infrahub/api/diff/diff.py index 97ee1e328c..ee34844f6f 100644 --- a/backend/infrahub/api/diff/diff.py +++ b/backend/infrahub/api/diff/diff.py @@ -1,13 +1,12 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, Request from infrahub.api.dependencies import get_branch_dep, get_current_user, get_db from infrahub.core import registry -from infrahub.core.branch import Branch # noqa: TC001 from infrahub.core.diff.artifacts.calculator import ArtifactDiffCalculator from infrahub.core.diff.branch_differ import BranchDiffer from infrahub.core.diff.model.diff import ( @@ -15,9 +14,11 @@ BranchDiffFile, BranchDiffRepository, ) -from infrahub.database import InfrahubDatabase # noqa: TC001 if TYPE_CHECKING: + from infrahub.auth import AccountSession + from infrahub.core.branch import Branch + from infrahub.database import InfrahubDatabase from infrahub.services import InfrahubServices @@ -29,17 +30,22 @@ async def get_diff_files( request: Request, db: InfrahubDatabase = Depends(get_db), branch: Branch = Depends(get_branch_dep), - time_from: Optional[str] = None, - time_to: Optional[str] = None, + time_from: str | None = None, + time_to: str | None = None, branch_only: bool = True, - _: str = Depends(get_current_user), + _: AccountSession = Depends(get_current_user), ) -> dict[str, dict[str, BranchDiffRepository]]: response: dict[str, dict[str, BranchDiffRepository]] = defaultdict(dict) service: InfrahubServices = request.app.state.service # Query the Diff for all files and repository from the database diff = await BranchDiffer.init( - db=db, branch=branch, diff_from=time_from, diff_to=time_to, branch_only=branch_only, service=service + db=db, + branch=branch, + diff_from=time_from, + diff_to=time_to, + branch_only=branch_only, + service=service, ) diff_files = await diff.get_files() diff --git a/backend/infrahub/api/file.py b/backend/infrahub/api/file.py index f14bd05fcd..2d8afd0029 100644 --- a/backend/infrahub/api/file.py +++ b/backend/infrahub/api/file.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, Request from starlette.responses import PlainTextResponse @@ -27,13 +27,13 @@ async def get_file( file_path: str, branch_params: BranchParams = Depends(get_branch_params), db: InfrahubDatabase = Depends(get_db), - commit: Optional[str] = None, + commit: str | None = None, _: str = Depends(get_current_user), ) -> PlainTextResponse: """Retrieve a file from a git repository.""" service: InfrahubServices = request.app.state.service - repo: Union[CoreRepository, CoreReadOnlyRepository] = await NodeManager.get_one_by_id_or_default_filter( + repo: CoreRepository | CoreReadOnlyRepository = await NodeManager.get_one_by_id_or_default_filter( db=db, id=repository_id, kind=InfrahubKind.GENERICREPOSITORY, diff --git a/backend/infrahub/api/internal.py b/backend/infrahub/api/internal.py index ff57042f90..4e8b47f5a2 100644 --- a/backend/infrahub/api/internal.py +++ b/backend/infrahub/api/internal.py @@ -1,16 +1,27 @@ +from __future__ import annotations + import re -from typing import Optional +from typing import TYPE_CHECKING import ujson -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from lunr.index import Index from pydantic import BaseModel from infrahub import config -from infrahub.config import AnalyticsSettings, ExperimentalFeaturesSettings, LoggingSettings, MainSettings +from infrahub.api.dependencies import get_current_user +from infrahub.config import ( # noqa: TC001 + AnalyticsSettings, + ExperimentalFeaturesSettings, + LoggingSettings, + MainSettings, +) from infrahub.core import registry from infrahub.exceptions import NodeNotFoundError +if TYPE_CHECKING: + from infrahub.auth import AccountSession + router = APIRouter() @@ -39,7 +50,7 @@ async def get_config() -> ConfigAPI: @router.get("/info") -async def get_info(request: Request) -> InfoAPI: +async def get_info(request: Request, _: AccountSession = Depends(get_current_user)) -> InfoAPI: return InfoAPI(deployment_id=str(registry.id), version=request.app.version) @@ -47,7 +58,7 @@ class SearchDocs: def __init__(self) -> None: self._title_documents: list[dict] = [] self._heading_documents: list[dict] = [] - self._heading_index: Optional[Index] = None + self._heading_index: Index | None = None def _load_json(self) -> None: """ @@ -142,7 +153,9 @@ class SearchResultAPI(BaseModel): @router.get("/search/docs", include_in_schema=False) -async def search_docs(query: str, limit: Optional[int] = None) -> list[SearchResultAPI]: +async def search_docs( + query: str, limit: int | None = None, _: AccountSession = Depends(get_current_user) +) -> list[SearchResultAPI]: smart_query = smart_queries(query) search_results = search_docs_loader.heading_index.search(smart_query) heading_results = [ diff --git a/backend/infrahub/api/schema.py b/backend/infrahub/api/schema.py index 39c6659e36..c506d98802 100644 --- a/backend/infrahub/api/schema.py +++ b/backend/infrahub/api/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, Query, Request from pydantic import ( @@ -72,17 +72,17 @@ def set_kind(cls, values: Any) -> Any: class APINodeSchema(NodeSchema, APISchemaMixin): - api_kind: Optional[str] = Field(default=None, alias="kind", validate_default=True) + api_kind: str | None = Field(default=None, alias="kind", validate_default=True) hash: str class APIGenericSchema(GenericSchema, APISchemaMixin): - api_kind: Optional[str] = Field(default=None, alias="kind", validate_default=True) + api_kind: str | None = Field(default=None, alias="kind", validate_default=True) hash: str class APIProfileSchema(ProfileSchema, APISchemaMixin): - api_kind: Optional[str] = Field(default=None, alias="kind", validate_default=True) + api_kind: str | None = Field(default=None, alias="kind", validate_default=True) hash: str @@ -103,16 +103,16 @@ class SchemasLoadAPI(BaseModel): class JSONSchema(BaseModel): - title: Optional[str] = Field(None, description="Title of the schema") - description: Optional[str] = Field(None, description="Description of the schema") + title: str | None = Field(None, description="Title of the schema") + description: str | None = Field(None, description="Description of the schema") type: str = Field(..., description="Type of the schema element (e.g., 'object', 'array', 'string')") - properties: Optional[dict[str, Any]] = Field(None, description="Properties of the object if type is 'object'") - items: Optional[Union[dict[str, Any], list[dict[str, Any]]]] = Field( + properties: dict[str, Any] | None = Field(None, description="Properties of the object if type is 'object'") + items: dict[str, Any] | list[dict[str, Any]] | None = Field( None, description="Items of the array if type is 'array'" ) - required: Optional[list[str]] = Field(None, description="List of required properties if type is 'object'") - schema_spec: Optional[str] = Field(None, alias="$schema", description="Schema version identifier") - additional_properties: Optional[Union[bool, dict[str, Any]]] = Field( + required: list[str] | None = Field(None, description="List of required properties if type is 'object'") + schema_spec: str | None = Field(None, alias="$schema", description="Schema version identifier") + additional_properties: bool | dict[str, Any] | None = Field( None, description="Specifies whether additional properties are allowed", alias="additionalProperties" ) @@ -152,7 +152,9 @@ def evaluate_candidate_schemas( @router.get("") async def get_schema( - branch: Branch = Depends(get_branch_dep), namespaces: Union[list[str], None] = Query(default=None) + branch: Branch = Depends(get_branch_dep), + namespaces: list[str] | None = Query(default=None), + _: AccountSession = Depends(get_current_user), ) -> SchemaReadAPI: log.debug("schema_request", branch=branch.name) schema_branch = registry.schema.get_schema_branch(name=branch.name) @@ -180,7 +182,9 @@ async def get_schema( @router.get("/summary") -async def get_schema_summary(branch: Branch = Depends(get_branch_dep)) -> SchemaBranchHash: +async def get_schema_summary( + branch: Branch = Depends(get_branch_dep), _: AccountSession = Depends(get_current_user) +) -> SchemaBranchHash: log.debug("schema_summary_request", branch=branch.name) schema_branch = registry.schema.get_schema_branch(name=branch.name) return schema_branch.get_hash_full() @@ -188,13 +192,13 @@ async def get_schema_summary(branch: Branch = Depends(get_branch_dep)) -> Schema @router.get("/{schema_kind}") async def get_schema_by_kind( - schema_kind: str, branch: Branch = Depends(get_branch_dep) -) -> Union[APIProfileSchema, APINodeSchema, APIGenericSchema]: + schema_kind: str, branch: Branch = Depends(get_branch_dep), _: AccountSession = Depends(get_current_user) +) -> APIProfileSchema | APINodeSchema | APIGenericSchema: log.debug("schema_kind_request", branch=branch.name) schema = registry.schema.get(name=schema_kind, branch=branch, duplicate=False) - api_schema: dict[str, type[Union[APIProfileSchema, APINodeSchema, APIGenericSchema]]] = { + api_schema: dict[str, type[APIProfileSchema | APINodeSchema | APIGenericSchema]] = { "profile": APIProfileSchema, "node": APINodeSchema, "generic": APIGenericSchema, @@ -212,7 +216,9 @@ async def get_schema_by_kind( @router.get("/json_schema/{schema_kind}") -async def get_json_schema_by_kind(schema_kind: str, branch: Branch = Depends(get_branch_dep)) -> JSONSchema: +async def get_json_schema_by_kind( + schema_kind: str, branch: Branch = Depends(get_branch_dep), _: AccountSession = Depends(get_current_user) +) -> JSONSchema: log.debug("json_schema_kind_request", branch=branch.name) fields: dict[str, Any] = {} @@ -368,7 +374,7 @@ async def check_schema( request: Request, schemas: SchemasLoadAPI, branch: Branch = Depends(get_branch_dep), - _: Any = Depends(get_current_user), + _: AccountSession = Depends(get_current_user), ) -> JSONResponse: service: InfrahubServices = request.app.state.service log.info("schema_check_request", branch=branch.name) diff --git a/backend/infrahub/api/storage.py b/backend/infrahub/api/storage.py index 77b903abc7..072363ccc5 100644 --- a/backend/infrahub/api/storage.py +++ b/backend/infrahub/api/storage.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import hashlib +from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, File, Response, UploadFile from infrahub_sdk.uuidt import UUIDT @@ -8,6 +11,9 @@ from infrahub.core import registry from infrahub.log import get_logger +if TYPE_CHECKING: + from infrahub.auth import AccountSession + log = get_logger() router = APIRouter(prefix="/storage") @@ -22,10 +28,7 @@ class UploadContentPayload(BaseModel): @router.get("/object/{identifier:str}") -def get_file( - identifier: str, - _: str = Depends(get_current_user), -) -> Response: +def get_file(identifier: str, _: AccountSession = Depends(get_current_user)) -> Response: content = registry.storage.retrieve(identifier=identifier) return Response(content=content) @@ -48,10 +51,7 @@ def upload_content( @router.post("/upload/file") -def upload_file( - file: UploadFile = File(...), - _: str = Depends(get_current_user), -) -> UploadResponse: +def upload_file(file: UploadFile = File(...), _: AccountSession = Depends(get_current_user)) -> UploadResponse: # TODO need to optimized how we read the content of the file, especially if the file is really large # Check this discussion for more details # https://stackoverflow.com/questions/63048825/how-to-upload-file-using-fastapi diff --git a/backend/infrahub/api/transformation.py b/backend/infrahub/api/transformation.py index 0418588150..280164e52a 100644 --- a/backend/infrahub/api/transformation.py +++ b/backend/infrahub/api/transformation.py @@ -27,6 +27,7 @@ from infrahub.workflows.catalogue import TRANSFORM_JINJA2_RENDER, TRANSFORM_PYTHON_RENDER if TYPE_CHECKING: + from infrahub.auth import AccountSession from infrahub.services import InfrahubServices router = APIRouter() @@ -38,7 +39,7 @@ async def transform_python( transform_id: str, db: InfrahubDatabase = Depends(get_db), branch_params: BranchParams = Depends(get_branch_params), - _: str = Depends(get_current_user), + _: AccountSession = Depends(get_current_user), ) -> JSONResponse: params = {key: value for key, value in request.query_params.items() if key not in ["branch", "at"]} @@ -97,7 +98,7 @@ async def transform_jinja2( transform_id: str = Path(description="ID or Name of the Jinja2 Transform to render"), db: InfrahubDatabase = Depends(get_db), branch_params: BranchParams = Depends(get_branch_params), - _: str = Depends(get_current_user), + _: AccountSession = Depends(get_current_user), ) -> PlainTextResponse: params = {key: value for key, value in request.query_params.items() if key not in ["branch", "at"]} diff --git a/backend/infrahub/graphql/api/endpoints.py b/backend/infrahub/graphql/api/endpoints.py index c7cd4d52bd..8be5578740 100644 --- a/backend/infrahub/graphql/api/endpoints.py +++ b/backend/infrahub/graphql/api/endpoints.py @@ -1,15 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from fastapi import APIRouter, Depends from fastapi.responses import PlainTextResponse from graphql import print_schema from starlette.routing import Route, WebSocketRoute -from infrahub.api.dependencies import get_branch_dep +from infrahub.api.dependencies import get_branch_dep, get_current_user from infrahub.core import registry -from infrahub.core.branch import Branch from infrahub.graphql.manager import GraphQLSchemaManager from .dependencies import build_graphql_app +if TYPE_CHECKING: + from infrahub.auth import AccountSession + from infrahub.core.branch import Branch + router = APIRouter(redirect_slashes=False) @@ -21,7 +28,9 @@ @router.get("/schema.graphql", include_in_schema=False) -async def get_graphql_schema(branch: Branch = Depends(get_branch_dep)) -> PlainTextResponse: +async def get_graphql_schema( + branch: Branch = Depends(get_branch_dep), _: AccountSession = Depends(get_current_user) +) -> PlainTextResponse: schema_branch = registry.schema.get_schema_branch(name=branch.name) gqlm = GraphQLSchemaManager.get_manager_for_branch(branch=branch, schema_branch=schema_branch) graphql_schema = gqlm.get_graphql_schema() diff --git a/backend/tests/unit/api/test_auth.py b/backend/tests/unit/api/test_00_auth.py similarity index 100% rename from backend/tests/unit/api/test_auth.py rename to backend/tests/unit/api/test_00_auth.py diff --git a/backend/tests/unit/api/test_auth_cookies.py b/backend/tests/unit/api/test_01_auth_cookies.py similarity index 100% rename from backend/tests/unit/api/test_auth_cookies.py rename to backend/tests/unit/api/test_01_auth_cookies.py diff --git a/backend/tests/unit/api/test_02_base.py b/backend/tests/unit/api/test_02_base.py new file mode 100644 index 0000000000..33d2147681 --- /dev/null +++ b/backend/tests/unit/api/test_02_base.py @@ -0,0 +1,19 @@ +from fastapi.testclient import TestClient + +from infrahub.core.branch import Branch +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.database import InfrahubDatabase + + +async def test_get_invalid( + client: TestClient, db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch +): + with client: + response = client.get("/api/so-such-route") + + assert response.status_code == 404 + assert response.json() + assert response.json()["errors"] + assert response.json()["errors"] == [ + {"message": "The requested endpoint /api/so-such-route does not exist", "extensions": {"code": 404}} + ] diff --git a/backend/tests/unit/api/test_menu.py b/backend/tests/unit/api/test_03_menu.py similarity index 85% rename from backend/tests/unit/api/test_menu.py rename to backend/tests/unit/api/test_03_menu.py index acdf53fa31..06e63ffb36 100644 --- a/backend/tests/unit/api/test_menu.py +++ b/backend/tests/unit/api/test_03_menu.py @@ -15,10 +15,7 @@ async def test_get_menu_not_admin( await create_default_menu(db=db) with client: - response = client.get( - "/api/menu", - headers=client_headers, - ) + response = client.get("/api/menu", headers=client_headers) assert response.status_code == 200 assert response.json() is not None @@ -39,10 +36,7 @@ async def test_get_menu_admin( await create_default_menu(db=db) with client: - response = client.get( - "/api/menu", - headers=admin_headers, - ) + response = client.get("/api/menu", headers=admin_headers) assert response.status_code == 200 assert response.json() is not None diff --git a/backend/tests/unit/api/test_05_query_api.py b/backend/tests/unit/api/test_10_query.py similarity index 94% rename from backend/tests/unit/api/test_05_query_api.py rename to backend/tests/unit/api/test_10_query.py index 9d9b72ba3c..28e9f7e2a5 100644 --- a/backend/tests/unit/api/test_05_query_api.py +++ b/backend/tests/unit/api/test_10_query.py @@ -5,6 +5,7 @@ import pytest +from infrahub import config from infrahub.core.initialization import create_branch from infrahub.groups.models import RequestGraphQLQueryGroupUpdate from infrahub.workflows.catalogue import GRAPHQL_QUERY_GROUP_UPDATE @@ -271,3 +272,16 @@ async def test_query_endpoint_missing_privs( error = response.json() assert error["errors"] assert "You do not have one of the following permissions" in error["errors"][0]["message"] + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_query_endpoint_anonymous_account( + db: InfrahubDatabase, client: TestClient, default_branch, car_person_data, allow_anonymous_access: bool +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/query/query01") + + # 403 when access is allowed is fine, due to missing permission + assert response.status_code == 403 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_11_artifact.py b/backend/tests/unit/api/test_11_artifact.py index ec133c8970..144cf70fde 100644 --- a/backend/tests/unit/api/test_11_artifact.py +++ b/backend/tests/unit/api/test_11_artifact.py @@ -1,7 +1,9 @@ from unittest.mock import call, patch +import pytest from starlette.testclient import TestClient +from infrahub import config from infrahub.core import registry from infrahub.core.constants import InfrahubKind from infrahub.core.node import Node @@ -13,23 +15,15 @@ class TestArtifact11(TestInfrahubApp): - async def test_artifact_definition_endpoint( - self, - db: InfrahubDatabase, - admin_headers, - default_branch, - register_core_models_schema, - register_builtin_models_schema, - car_person_data_generic, - authentication_base, - client, - ): - g1 = await Node.init(db=db, schema=InfrahubKind.STANDARDGROUP) - await g1.new(db=db, name="group1", members=[car_person_data_generic["c1"], car_person_data_generic["c2"]]) - await g1.save(db=db) - - t1 = await Node.init(db=db, schema="CoreTransformPython") - await t1.new( + async def setup_artifact_definition( + self, db: InfrahubDatabase, register_core_models_schema, register_builtin_models_schema, car_person_data_generic + ) -> tuple[Node, Node, Node]: + group = await Node.init(db=db, schema=InfrahubKind.STANDARDGROUP) + await group.new(db=db, name="group1", members=[car_person_data_generic["c1"], car_person_data_generic["c2"]]) + await group.save(db=db) + + transform = await Node.init(db=db, schema="CoreTransformPython") + await transform.new( db=db, name="transform01", query=str(car_person_data_generic["q1"].id), @@ -37,19 +31,66 @@ async def test_artifact_definition_endpoint( file_path="transform01.py", class_name="Transform01", ) - await t1.save(db=db) + await transform.save(db=db) - ad1 = await Node.init(db=db, schema=InfrahubKind.ARTIFACTDEFINITION) - await ad1.new( + definition = await Node.init(db=db, schema=InfrahubKind.ARTIFACTDEFINITION) + await definition.new( db=db, name="artifactdef01", - targets=g1, - transformation=t1, + targets=group, + transformation=transform, content_type="application/json", artifact_name="myartifact", parameters={"value": {"name": "name__value"}}, ) - await ad1.save(db=db) + await definition.save(db=db) + + return group, transform, definition + + async def setup_artifact( + self, db: InfrahubDatabase, register_core_models_schema, register_builtin_models_schema, car_person_data_generic + ) -> Node: + _, _, definition = await self.setup_artifact_definition( + db=db, + register_core_models_schema=register_core_models_schema, + register_builtin_models_schema=register_builtin_models_schema, + car_person_data_generic=car_person_data_generic, + ) + + artifact = await Node.init(db=db, schema=InfrahubKind.ARTIFACT) + await artifact.new( + db=db, + name="myyartifact", + definition=definition, + status="Ready", + object=car_person_data_generic["c1"], + storage_id="95008984-16ca-4e58-8323-0899bb60035f", + checksum="60d39063c26263353de24e1b913e1e1c", + content_type="application/json", + ) + await artifact.save(db=db) + + registry.storage.store(identifier="95008984-16ca-4e58-8323-0899bb60035f", content='{"test": true}'.encode()) + + return artifact + + async def test_artifact_definition_endpoint( + self, + db: InfrahubDatabase, + admin_headers, + default_branch, + register_core_models_schema, + register_builtin_models_schema, + car_person_data_generic, + authentication_base, + client, + ): + _, _, definition = await self.setup_artifact_definition( + db=db, + register_core_models_schema=register_core_models_schema, + register_builtin_models_schema=register_builtin_models_schema, + car_person_data_generic=car_person_data_generic, + ) app_client = TestClient(app) @@ -61,7 +102,7 @@ async def test_artifact_definition_endpoint( ) as mock_submit_workflow, ): response = app_client.post( - f"/api/artifact/generate/{ad1.id}", + f"/api/artifact/generate/{definition.id}", headers=admin_headers, ) @@ -70,7 +111,9 @@ async def test_artifact_definition_endpoint( call( workflow=REQUEST_ARTIFACT_DEFINITION_GENERATE, parameters={ - "model": RequestArtifactDefinitionGenerate(artifact_definition=ad1.id, branch="main", limit=[]) + "model": RequestArtifactDefinitionGenerate( + artifact_definition=definition.id, branch="main", limit=[] + ) }, ), ] @@ -91,50 +134,40 @@ async def test_artifact_endpoint( response = app_client.get("/api/artifact/95008984-16ca-4e58-8323-0899bb60035f", headers=admin_headers) assert response.status_code == 404 - g1 = await Node.init(db=db, schema=InfrahubKind.STANDARDGROUP) - await g1.new(db=db, name="group1", members=[car_person_data_generic["c1"], car_person_data_generic["c2"]]) - await g1.save(db=db) - - t1 = await Node.init(db=db, schema="CoreTransformPython") - await t1.new( + artifact = await self.setup_artifact( db=db, - name="transform01", - query=str(car_person_data_generic["q1"].id), - repository=str(car_person_data_generic["r1"].id), - file_path="transform01.py", - class_name="Transform01", + register_core_models_schema=register_core_models_schema, + register_builtin_models_schema=register_builtin_models_schema, + car_person_data_generic=car_person_data_generic, ) - await t1.save(db=db) - ad1 = await Node.init(db=db, schema=InfrahubKind.ARTIFACTDEFINITION) - await ad1.new( - db=db, - name="artifactdef01", - targets=g1, - transformation=t1, - content_type="application/json", - artifact_name="myartifact", - parameters={"value": {"name": "name__value"}}, - ) - await ad1.save(db=db) + with app_client: + response = app_client.get(f"/api/artifact/{artifact.id}", headers=admin_headers) - art1 = await Node.init(db=db, schema=InfrahubKind.ARTIFACT) - await art1.new( + assert response.status_code == 200 + assert response.json() == {"test": True} + + @pytest.mark.parametrize("allow_anonymous_access", [False, True]) + async def test_artifact_endpoint_anonymous_account( + self, + db: InfrahubDatabase, + register_core_models_schema, + register_builtin_models_schema, + car_person_data_generic, + allow_anonymous_access: bool, + ): + app_client = TestClient(app) + + artifact = await self.setup_artifact( db=db, - name="myyartifact", - definition=ad1, - status="Ready", - object=car_person_data_generic["c1"], - storage_id="95008984-16ca-4e58-8323-0899bb60035f", - checksum="60d39063c26263353de24e1b913e1e1c", - content_type="application/json", + register_core_models_schema=register_core_models_schema, + register_builtin_models_schema=register_builtin_models_schema, + car_person_data_generic=car_person_data_generic, ) - await art1.save(db=db) - registry.storage.store(identifier="95008984-16ca-4e58-8323-0899bb60035f", content='{"test": true}'.encode()) + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access with app_client: - response = app_client.get(f"/api/artifact/{art1.id}", headers=admin_headers) + response = app_client.get(f"/api/artifact/{artifact.id}") - assert response.status_code == 200 - assert response.json() == {"test": True} + assert response.status_code == 200 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_12_file.py b/backend/tests/unit/api/test_12_file.py index 3a4a966e46..71020ce614 100644 --- a/backend/tests/unit/api/test_12_file.py +++ b/backend/tests/unit/api/test_12_file.py @@ -1,3 +1,6 @@ +import pytest + +from infrahub import config from infrahub.core.constants import InfrahubKind from infrahub.core.node import Node from infrahub.database import InfrahubDatabase @@ -63,3 +66,27 @@ async def test_get_file( assert response.status_code == 200 assert response.text == "file content" + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_get_file_anonymous_account( + db: InfrahubDatabase, client, default_branch, rpc_bus, register_core_models_schema, allow_anonymous_access: bool +): + r1 = await Node.init(db=db, schema=InfrahubKind.REPOSITORY) + await r1.new( + db=db, name="repo01", location="git@github.com:user/repo01.git", commit="1345754212345678iuytrewqwertyu" + ) + await r1.save(db=db) + + # Must execute in a with block to execute the startup/shutdown events + with client: + mock_response = messages.GitFileGetResponse( + data={"content": "file content"}, + ) + rpc_bus.add_mock_reply(response=mock_response) + + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + response = client.get(f"/api/file/{r1.id}/myfile.text?commit=12345678iuytrewqwertyu") + + assert response.status_code == 200 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_15_diff.py b/backend/tests/unit/api/test_15_diff.py index 012e6ac010..339e262e25 100644 --- a/backend/tests/unit/api/test_15_diff.py +++ b/backend/tests/unit/api/test_15_diff.py @@ -1,5 +1,6 @@ import pytest +from infrahub import config from infrahub.core.constants import NULL_VALUE, InfrahubKind from infrahub.core.diff.payload_builder import get_display_labels, get_display_labels_per_kind from infrahub.core.initialization import create_branch @@ -203,3 +204,27 @@ async def test_diff_artifact(db: InfrahubDatabase, client, client_headers, car_p for artifact_id, serial_artifact in expected_response.items(): assert data[artifact_id] == serial_artifact + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_diff_artifact_anonymous_access( + db: InfrahubDatabase, client, client_headers, car_person_data_artifact_diff, allow_anonymous_access: bool +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/diff/artifacts?branch=branch3") + + assert response.status_code == 200 if allow_anonymous_access else 401 + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_diff_files_anonymous_access( + db: InfrahubDatabase, client, client_headers, car_person_data_artifact_diff, allow_anonymous_access: bool +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/diff/files?branch=branch3") + + assert response.status_code == 200 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_20_graphql_api.py b/backend/tests/unit/api/test_20_graphql.py similarity index 91% rename from backend/tests/unit/api/test_20_graphql_api.py rename to backend/tests/unit/api/test_20_graphql.py index 3261ba2c48..4311ff88bf 100644 --- a/backend/tests/unit/api/test_20_graphql_api.py +++ b/backend/tests/unit/api/test_20_graphql.py @@ -1,5 +1,6 @@ import pytest +from infrahub import config from infrahub.core.branch import Branch from infrahub.core.initialization import create_branch from infrahub.core.timestamp import Timestamp @@ -207,3 +208,17 @@ async def test_download_schema(db: InfrahubDatabase, client, client_headers): response = client.get("/schema.graphql?branch=notvalid", headers=client_headers) assert response.status_code == 400 + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_download_schema_anonymous_account( + db: InfrahubDatabase, client, client_headers, allow_anonymous_access: bool +): + await create_branch(branch_name="branch2", db=db) + + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + # Must execute in a with block to execute the startup/shutdown events + with client: + response = client.get("/schema.graphql") + assert response.status_code == 200 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_40_schema_api.py b/backend/tests/unit/api/test_40_schema.py similarity index 92% rename from backend/tests/unit/api/test_40_schema_api.py rename to backend/tests/unit/api/test_40_schema.py index 12ad155e23..83c83afe04 100644 --- a/backend/tests/unit/api/test_40_schema_api.py +++ b/backend/tests/unit/api/test_40_schema.py @@ -1,5 +1,7 @@ +import pytest from fastapi.testclient import TestClient +from infrahub import config from infrahub.core import registry from infrahub.core.branch import Branch from infrahub.core.constants import InfrahubKind @@ -383,3 +385,34 @@ async def test_schema_load_endpoint_constraints_not_valid( "errors": [{"extensions": {"code": 422}, "message": error_message}], } assert response.status_code == 422 + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_schema_read_endpoints_anonymous_account( + db: InfrahubDatabase, + client: TestClient, + default_branch: Branch, + car_person_schema_generics: SchemaRoot, + allow_anonymous_access: bool, +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/schema") + + assert response.status_code == 200 if allow_anonymous_access else 401 + + with client: + response = client.get("/api/schema/TestCar") + + assert response.status_code == 200 if allow_anonymous_access else 401 + + with client: + response = client.get("/api/schema/summary") + + assert response.status_code == 200 if allow_anonymous_access else 401 + + with client: + response = client.get("/api/schema/json_schema/TestCar") + + assert response.status_code == 200 if allow_anonymous_access else 401 diff --git a/backend/tests/unit/api/test_50_config_api.py b/backend/tests/unit/api/test_50_config_api.py deleted file mode 100644 index f9dfd6fabc..0000000000 --- a/backend/tests/unit/api/test_50_config_api.py +++ /dev/null @@ -1,18 +0,0 @@ -from infrahub.database import InfrahubDatabase - - -async def test_config_endpoint( - db: InfrahubDatabase, client, client_headers, default_branch, register_core_models_schema: None -): - with client: - response = client.get( - "/api/config", - headers=client_headers, - ) - - assert response.status_code == 200 - assert response.json() is not None - - config = response.json() - - assert sorted(config.keys()) == ["analytics", "experimental_features", "logging", "main", "sso"] diff --git a/backend/tests/unit/api/test_internal.py b/backend/tests/unit/api/test_50_internals.py similarity index 54% rename from backend/tests/unit/api/test_internal.py rename to backend/tests/unit/api/test_50_internals.py index 845b955bf9..9335ae0a98 100644 --- a/backend/tests/unit/api/test_internal.py +++ b/backend/tests/unit/api/test_50_internals.py @@ -2,9 +2,62 @@ from infrahub import config from infrahub.api import internal +from infrahub.database import InfrahubDatabase from tests.helpers.fixtures import get_fixtures_dir +async def test_config_endpoint( + db: InfrahubDatabase, client, client_headers, default_branch, register_core_models_schema: None +): + with client: + response = client.get("/api/config", headers=client_headers) + + assert response.status_code == 200 + assert response.json() is not None + + result = response.json() + + assert sorted(result.keys()) == ["analytics", "experimental_features", "logging", "main", "sso"] + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_config_endpoint_anonymous_account( + db: InfrahubDatabase, client, default_branch, register_core_models_schema: None, allow_anonymous_access: bool +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/config") + + assert response.status_code == 200 + + +async def test_info_endpoint( + db: InfrahubDatabase, client, client_headers, default_branch, register_core_models_schema: None +): + with client: + response = client.get("/api/info", headers=client_headers) + + assert response.status_code == 200 + assert response.json() is not None + + result = response.json() + + assert sorted(result.keys()) == ["deployment_id", "version"] + + +@pytest.mark.parametrize("allow_anonymous_access", [False, True]) +async def test_info_endpoint_anonymous_account( + db: InfrahubDatabase, client, default_branch, register_core_models_schema: None, allow_anonymous_access: bool +): + config.SETTINGS.main.allow_anonymous_access = allow_anonymous_access + + with client: + response = client.get("/api/info") + + assert response.status_code == 200 if allow_anonymous_access else 401 + + @pytest.fixture def override_search_index_path(): old_search_index_path = config.SETTINGS.main.docs_index_path diff --git a/backend/tests/unit/api/test_openapi.py b/backend/tests/unit/api/test_70_openapi.py similarity index 82% rename from backend/tests/unit/api/test_openapi.py rename to backend/tests/unit/api/test_70_openapi.py index 8b209e52b2..7db44941fc 100644 --- a/backend/tests/unit/api/test_openapi.py +++ b/backend/tests/unit/api/test_70_openapi.py @@ -6,9 +6,7 @@ async def test_openapi(client: TestClient, default_branch: Branch, register_core_models_schema: None) -> None: """Validate that the OpenAPI specs can be generated.""" with client: - response = client.get( - "/api/openapi.json", - ) + response = client.get("/api/openapi.json") assert response.status_code == 200 assert response.json() is not None diff --git a/backend/tests/unit/api/test_api_base.py b/backend/tests/unit/api/test_api_base.py deleted file mode 100644 index 0057268231..0000000000 --- a/backend/tests/unit/api/test_api_base.py +++ /dev/null @@ -1,12 +0,0 @@ -async def test_get_invalid(client, db): - with client: - response = client.get( - "/api/so-such-route", - ) - - assert response.status_code == 404 - assert response.json() - assert response.json()["errors"] - assert response.json()["errors"] == [ - {"message": "The requested endpoint /api/so-such-route does not exist", "extensions": {"code": 404}} - ] diff --git a/changelog/5312.fixed.md b/changelog/5312.fixed.md new file mode 100644 index 0000000000..5dbdeaea75 --- /dev/null +++ b/changelog/5312.fixed.md @@ -0,0 +1 @@ +Prevent access to REST API endpoints for anonymous user when anonymous access is not allowed \ No newline at end of file diff --git a/frontend/app/src/utils/fetch.ts b/frontend/app/src/utils/fetch.ts index 63a1872aca..c03a1bcc46 100644 --- a/frontend/app/src/utils/fetch.ts +++ b/frontend/app/src/utils/fetch.ts @@ -1,10 +1,14 @@ +import { ACCESS_TOKEN_KEY } from "@/config/localStorage"; import { QSP } from "@/config/qsp"; export const fetchUrl = async (url: string, payload?: RequestInit) => { + const localToken = localStorage.getItem(ACCESS_TOKEN_KEY); + const newPayload = { headers: { Accept: "application/json", "Content-Type": "application/json", + ...(localToken ? { authorization: `Bearer ${localToken}` } : {}), ...payload?.headers, }, method: payload?.method ?? "GET",