Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Boefje and Normalizer models in the KATalogus #3011

Merged
merged 31 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f7749e9
WIP: removed references to Repositories
Donnype May 21, 2024
d137794
Create brute-force migration
Donnype May 22, 2024
8736f9f
Fix migrations test setup
Donnype May 22, 2024
72f7ba1
Fail explicitly on non-local plugins being present
Donnype May 22, 2024
e12961e
More cleanup, renaming and moving code to create the structure we use…
Donnype May 23, 2024
ef3d4d0
PR feedback: Base64Str type for env vars
Donnype May 28, 2024
4211d4b
Fix test comment on revision id
Donnype May 28, 2024
b7cd08e
Add database models for boefje and normalizer
Donnype May 28, 2024
21852a5
Add repository and api for boefje and normalizer models
Donnype May 28, 2024
943965e
Add patch and delete endpoints including tests
Donnype May 28, 2024
7e09f73
Some style fixes, comments, and cleaning of errors and context manage…
Donnype May 29, 2024
79cc0eb
Fix settings api bug
Donnype May 29, 2024
8790ab9
Change environment keys backward compatible
Donnype May 29, 2024
46e00ba
Fix KATalogus entrypoints
Donnype May 29, 2024
27324d0
Merge branch 'feature/persisting-plugins' into feature/boefje-normali…
Donnype May 29, 2024
6eac6e3
Merge branch 'main' into feature/persisting-plugins
Donnype May 30, 2024
aa9f613
Documentation on phasing out the repository model in the release notes
Donnype May 30, 2024
a599337
Merge branch 'feature/persisting-plugins' into feature/boefje-normali…
Donnype May 30, 2024
38a330f
Merge branch 'main' into feature/persisting-plugins
Donnype May 30, 2024
a5740d5
Merge branch 'feature/persisting-plugins' into feature/boefje-normali…
Donnype May 30, 2024
3eebead
Merge branch 'main' into feature/boefje-normalizer-models
Donnype May 30, 2024
bd25215
Disable the option to edit the id field on boefje and normalizers
Donnype Jun 1, 2024
79e3ee7
Do not allow CRUD operations for local (static) plugins
Donnype Jun 4, 2024
242cd9c
Merge branch 'main' into feature/boefje-normalizer-models
Donnype Jun 7, 2024
f02ec3c
Merge branch 'main' into feature/boefje-normalizer-models
Donnype Jun 11, 2024
b53fcee
Rename Patch models
Donnype Jun 11, 2024
424e05c
Merge branch 'main' into feature/boefje-normalizer-models
stephanie0x00 Jun 12, 2024
5b6d5a7
Merge branch 'main' into feature/boefje-normalizer-models
Donnype Jun 13, 2024
073e175
Merge branch 'main' into feature/boefje-normalizer-models
underdarknl Jun 13, 2024
1662bcb
Merge branch 'main' into feature/boefje-normalizer-models
underdarknl Jun 17, 2024
d40c066
Merge branch 'main' into feature/boefje-normalizer-models
underdarknl Jun 18, 2024
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
126 changes: 91 additions & 35 deletions boefjes/boefjes/katalogus/api/plugins.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import datetime
from functools import partial

from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.responses import FileResponse, JSONResponse, Response
from httpx import HTTPStatusError
from pydantic import BaseModel, Field

from boefjes.katalogus.api.organisations import check_organisation_exists
from boefjes.katalogus.dependencies.plugins import (
Expand All @@ -12,6 +13,8 @@
get_plugins_filter_parameters,
)
from boefjes.katalogus.models import FilterParameters, PaginationParameters, PluginType
from boefjes.katalogus.storage.interfaces import PluginStorage
from boefjes.sql.plugin_storage import get_plugin_storage

router = APIRouter(
prefix="/organisations/{organisation_id}",
Expand Down Expand Up @@ -82,46 +85,107 @@ def get_plugin(
return p.by_plugin_id(plugin_id, organisation_id)
except KeyError:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plugin not found")
except HTTPStatusError as ex:
raise HTTPException(ex.response.status_code)


@router.patch("/plugins/{plugin_id}")
@router.post("/plugins", status_code=status.HTTP_201_CREATED)
Donnype marked this conversation as resolved.
Show resolved Hide resolved
def add_plugin(plugin: PluginType, plugin_service: PluginService = Depends(get_plugin_service)):
with plugin_service as service:
if plugin.type == "boefje":
return service.create_boefje(plugin)

if plugin.type == "normalizer":
return service.create_normalizer(plugin)

raise HTTPException(status.HTTP_400_BAD_REQUEST, "Creation of Bits is not supported")


@router.patch("/plugins/{plugin_id}", status_code=status.HTTP_204_NO_CONTENT)
def update_plugin_state(
plugin_id: str,
organisation_id: str,
enabled: bool = Body(False, embed=True),
plugin_service: PluginService = Depends(get_plugin_service),
):
try:
with plugin_service as p:
p.update_by_id(plugin_id, organisation_id, enabled)
except HTTPStatusError as ex:
raise HTTPException(ex.response.status_code)
with plugin_service as p:
p.set_enabled_by_id(plugin_id, organisation_id, enabled)


class BoefjeIn(BaseModel):
"""
For patching, we need all fields to be optional, hence we overwrite the definition here.
Also see https://fastapi.tiangolo.com/tutorial/body-updates/ as a reference.
"""

name: str | None = None
version: str | None = None
created: datetime.datetime | None = None
description: str | None = None
environment_keys: list[str] = Field(default_factory=list)
scan_level: int = 1
consumes: set[str] = Field(default_factory=set)
produces: set[str] = Field(default_factory=set)
oci_image: str | None = None
oci_arguments: list[str] = Field(default_factory=list)


@router.patch("/boefjes/{boefje_id}", status_code=status.HTTP_204_NO_CONTENT)
def update_boefje(
boefje_id: str,
boefje: BoefjeIn,
storage: PluginStorage = Depends(get_plugin_storage),
):
with storage as p:
p.update_boefje(boefje_id, boefje.model_dump(exclude_unset=True))


@router.delete("/boefjes/{boefje_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_boefje(boefje_id: str, plugin_storage: PluginStorage = Depends(get_plugin_storage)):
with plugin_storage as p:
p.delete_boefje_by_id(boefje_id)


class NormalizerIn(BaseModel):
"""
For patching, we need all fields to be optional, hence we overwrite the definition here.
Also see https://fastapi.tiangolo.com/tutorial/body-updates/ as a reference.
"""

name: str | None = None
version: str | None = None
created: datetime.datetime | None = None
description: str | None = None
environment_keys: list[str] = Field(default_factory=list)
consumes: list[str] = Field(default_factory=list) # mime types (and/ or boefjes)
produces: list[str] = Field(default_factory=list) # oois


@router.patch("/normalizers/{normalizer_id}", status_code=status.HTTP_204_NO_CONTENT)
def update_normalizer(
normalizer_id: str,
normalizer: NormalizerIn,
storage: PluginStorage = Depends(get_plugin_storage),
):
with storage as p:
p.update_normalizer(normalizer_id, normalizer.model_dump(exclude_unset=True))


@router.delete("/normalizers/{normalizer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_normalizer(normalizer_id: str, plugin_storage: PluginStorage = Depends(get_plugin_storage)):
with plugin_storage as p:
p.delete_normalizer_by_id(normalizer_id)


@router.get("/plugins/{plugin_id}/schema.json", include_in_schema=False)
def get_plugin_schema(
plugin_id: str,
plugin_service: PluginService = Depends(get_plugin_service),
) -> JSONResponse:
try:
with plugin_service as p:
return JSONResponse(p.schema(plugin_id))
except HTTPStatusError as ex:
raise HTTPException(ex.response.status_code)
def get_plugin_schema(plugin_id: str, plugin_service: PluginService = Depends(get_plugin_service)) -> JSONResponse:
return JSONResponse(plugin_service.schema(plugin_id))


@router.get("/plugins/{plugin_id}/cover.jpg", include_in_schema=False)
def get_plugin_cover(
plugin_id: str,
plugin_service: PluginService = Depends(get_plugin_service),
) -> FileResponse:
try:
with plugin_service as p:
return FileResponse(p.cover(plugin_id))
except HTTPStatusError as ex:
raise HTTPException(ex.response.status_code)
return FileResponse(plugin_service.cover(plugin_id))


@router.get("/plugins/{plugin_id}/description.md", include_in_schema=False)
Expand All @@ -130,18 +194,10 @@ def get_plugin_description(
organisation_id: str,
plugin_service: PluginService = Depends(get_plugin_service),
) -> Response:
try:
with plugin_service as p:
return Response(p.description(plugin_id, organisation_id))
except HTTPStatusError as ex:
raise HTTPException(ex.response.status_code)
return Response(plugin_service.description(plugin_id, organisation_id))


@router.post("/settings/clone/{to_organisation_id}")
def clone_organisation_settings(
organisation_id: str,
to_organisation_id: str,
storage: PluginService = Depends(get_plugin_service),
):
@router.post("/settings/clone/{to_org}")
def clone_settings(organisation_id: str, to_org: str, storage: PluginService = Depends(get_plugin_service)):
with storage as store:
store.clone_settings_to_organisation(organisation_id, to_organisation_id)
store.clone_settings_to_organisation(organisation_id, to_org)
16 changes: 11 additions & 5 deletions boefjes/boefjes/katalogus/api/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from boefjes.config import settings
from boefjes.katalogus.api import organisations, plugins
from boefjes.katalogus.api import settings as settings_router
from boefjes.katalogus.storage.interfaces import StorageError
from boefjes.katalogus.storage.interfaces import NotFound, StorageError
from boefjes.katalogus.version import __version__

with settings.log_cfg.open() as f:
Expand Down Expand Up @@ -51,16 +51,22 @@
app.include_router(router, prefix="/v1")


@app.exception_handler(StorageError)
def entity_not_found_handler(request: Request, exc: StorageError):
logger.exception("some error", exc_info=exc)

@app.exception_handler(NotFound)
def entity_not_found_handler(request: Request, exc: NotFound):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"message": exc.message},
)


@app.exception_handler(StorageError)
def storage_error_handler(request: Request, exc: StorageError):
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"message": exc.message},
)


class ServiceHealth(BaseModel):
service: str
healthy: bool = False
Expand Down
48 changes: 41 additions & 7 deletions boefjes/boefjes/katalogus/dependencies/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
from sqlalchemy.orm import Session

from boefjes.katalogus.local_repository import LocalPluginRepository, get_local_repository
from boefjes.katalogus.models import FilterParameters, PaginationParameters, PluginType
from boefjes.katalogus.models import Boefje, FilterParameters, Normalizer, PaginationParameters, PluginType
from boefjes.katalogus.storage.interfaces import (
ExistingPluginId,
NotFound,
PluginEnabledStorage,
PluginStorage,
SettingsNotConformingToSchema,
SettingsStorage,
)
from boefjes.sql.db import session_managed_iterator
from boefjes.sql.plugin_enabled_storage import create_plugin_enabled_storage
from boefjes.sql.plugin_storage import create_plugin_storage
from boefjes.sql.setting_storage import create_setting_storage

logger = logging.getLogger(__name__)
Expand All @@ -27,24 +30,40 @@
class PluginService:
def __init__(
self,
plugin_storage: PluginStorage,
plugin_enabled_store: PluginEnabledStorage,
settings_storage: SettingsStorage,
local_repo: LocalPluginRepository,
):
self.plugin_storage = plugin_storage
self.plugin_enabled_store = plugin_enabled_store
self.settings_storage = settings_storage
self.local_repo = local_repo

def __enter__(self):
self.plugin_enabled_store.__enter__()
self.plugin_storage.__enter__()
self.settings_storage.__enter__()

return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.plugin_enabled_store.__exit__(exc_type, exc_val, exc_tb)
self.plugin_storage.__exit__(exc_type, exc_val, exc_tb)
self.settings_storage.__exit__(exc_type, exc_val, exc_tb)

def get_all(self, organisation_id: str) -> list[PluginType]:
return [self._set_plugin_enabled(plugin, organisation_id) for plugin in self.local_repo.get_all()]
all_plugins = self.get_all_without_enabled()

return [self._set_plugin_enabled(plugin, organisation_id) for plugin in all_plugins.values()]

def get_all_without_enabled(self):
all_plugins = {plugin.id: plugin for plugin in self.local_repo.get_all()}

for plugin in self.plugin_storage.get_all():
all_plugins[plugin.id] = plugin

return all_plugins

def by_plugin_id(self, plugin_id: str, organisation_id: str) -> PluginType:
all_plugins = self.get_all(organisation_id)
Expand Down Expand Up @@ -72,22 +91,36 @@ def get_all_settings(self, organisation_id: str, plugin_id: str):
return self.settings_storage.get_all(organisation_id, plugin_id)

def clone_settings_to_organisation(self, from_organisation: str, to_organisation: str):
# One requirement is that we also do not keep previously enabled boefjes enabled of they are not copied.
# One requirement is that only boefjes enabled in the from_organisation end up being enabled for the target.
for plugin_id in self.plugin_enabled_store.get_all_enabled(to_organisation):
self.update_by_id(plugin_id, to_organisation, enabled=False)
self.set_enabled_by_id(plugin_id, to_organisation, enabled=False)

for plugin in self.get_all(from_organisation):
if all_settings := self.get_all_settings(from_organisation, plugin.id):
self.upsert_settings(all_settings, to_organisation, plugin.id)

for plugin_id in self.plugin_enabled_store.get_all_enabled(from_organisation):
self.update_by_id(plugin_id, to_organisation, enabled=True)
self.set_enabled_by_id(plugin_id, to_organisation, enabled=True)

def upsert_settings(self, values: dict, organisation_id: str, plugin_id: str):
self._assert_settings_match_schema(values, organisation_id, plugin_id)

return self.settings_storage.upsert(values, organisation_id, plugin_id)

def create_boefje(self, boefje: Boefje) -> None:
try:
self.local_repo.by_id(boefje.id)
raise ExistingPluginId(boefje.id)
except KeyError:
self.plugin_storage.create_boefje(boefje)

def create_normalizer(self, normalizer: Normalizer) -> None:
try:
self.local_repo.by_id(normalizer.id)
raise ExistingPluginId(normalizer.id)
except KeyError:
self.plugin_storage.create_normalizer(normalizer)

def delete_settings(self, organisation_id: str, plugin_id: str):
self.settings_storage.delete(organisation_id, plugin_id)

Expand All @@ -96,7 +129,7 @@ def delete_settings(self, organisation_id: str, plugin_id: str):
except SettingsNotConformingToSchema:
logger.warning("Making sure %s is disabled for %s because settings are deleted", plugin_id, organisation_id)

self.update_by_id(plugin_id, organisation_id, False)
self.set_enabled_by_id(plugin_id, organisation_id, False)

def schema(self, plugin_id: str) -> dict | None:
return self.local_repo.schema(plugin_id)
Expand All @@ -119,7 +152,7 @@ def description(self, plugin_id: str, organisation_id: str) -> str:
logger.error("Plugin not found: %s", plugin_id)
return ""

def update_by_id(self, plugin_id: str, organisation_id: str, enabled: bool):
def set_enabled_by_id(self, plugin_id: str, organisation_id: str, enabled: bool):
if enabled:
all_settings = self.settings_storage.get_all(organisation_id, plugin_id)
self._assert_settings_match_schema(all_settings, organisation_id, plugin_id)
Expand Down Expand Up @@ -149,6 +182,7 @@ def _set_plugin_enabled(self, plugin: PluginType, organisation_id: str) -> Plugi
def get_plugin_service(organisation_id: str) -> Iterator[PluginService]:
def closure(session: Session):
return PluginService(
create_plugin_storage(session),
create_plugin_enabled_storage(session),
create_setting_storage(session),
get_local_repository(),
Expand Down
2 changes: 1 addition & 1 deletion boefjes/boefjes/katalogus/local_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def by_id(self, plugin_id: str) -> PluginType:
if plugin_id in normalizers:
return normalizers[plugin_id].normalizer

raise Exception(f"Can't find plugin {plugin_id}")
raise KeyError(f"Can't find plugin {plugin_id}")

def schema(self, id_: str) -> dict | None:
boefjes = self.resolve_boefjes()
Expand Down
4 changes: 1 addition & 3 deletions boefjes/boefjes/katalogus/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ class Plugin(BaseModel):
id: str
name: str | None = None
version: str | None = None
authors: list[str] | None = None
created: datetime.datetime | None = None
description: str | None = None
environment_keys: list[str] = Field(default_factory=list)
related: list[str] | None = None
enabled: bool = False
static: bool = True # We need to differentiate between local and remote plugins to know which ones can be deleted

def __str__(self):
return f"{self.id}:{self.version}"
Expand All @@ -30,7 +29,6 @@ class Boefje(Plugin):
scan_level: int = 1
consumes: set[str] = Field(default_factory=set)
produces: set[str] = Field(default_factory=set)
options: list[str] | None = None
runnable_hash: str | None = None
oci_image: str | None = None
oci_arguments: list[str] = Field(default_factory=list)
Expand Down
Loading