From 2f1179b5e32c157b6d481969d3137d1d83d75c70 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Sun, 24 Mar 2024 11:39:39 -0700 Subject: [PATCH] [mock_uss/tracer] Add auth and log management to tracer (#589) * Add auth to tracer * Add log management * Address comments --- monitoring/mock_uss/docker-compose.yaml | 1 + .../mock_uss/templates/tracer/base.html | 2 +- .../mock_uss/templates/tracer/logs.html | 30 +++++++++ .../tracer/observation_areas_ui.html | 12 ++-- .../tracer/observation_area_operations.py | 9 ++- .../mock_uss/tracer/observation_areas.py | 2 +- .../tracer/routes/observation_areas.py | 5 ++ monitoring/mock_uss/tracer/routes/ui.py | 61 ++++++++++++++++- monitoring/mock_uss/tracer/tracerlog.py | 4 +- monitoring/mock_uss/tracer/ui_auth.py | 65 +++++++++++++++++++ requirements.txt | 1 + 11 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 monitoring/mock_uss/tracer/ui_auth.py diff --git a/monitoring/mock_uss/docker-compose.yaml b/monitoring/mock_uss/docker-compose.yaml index f8fe5600ad..143ea5f8be 100644 --- a/monitoring/mock_uss/docker-compose.yaml +++ b/monitoring/mock_uss/docker-compose.yaml @@ -221,6 +221,7 @@ services: - MOCK_USS_TOKEN_AUDIENCE=tracer.uss4.localutm,localhost,host.docker.internal - MOCK_USS_BASE_URL=http://tracer.uss4.localutm - MOCK_USS_TRACER_OUTPUT_FOLDER=output/tracer + - MOCK_USS_TRACER_UI_USERS=admin@admin=admin;viewer= - MOCK_USS_SERVICES=tracer - MOCK_USS_PORT=80 expose: diff --git a/monitoring/mock_uss/templates/tracer/base.html b/monitoring/mock_uss/templates/tracer/base.html index 2b5253fd8c..b541af6b23 100644 --- a/monitoring/mock_uss/templates/tracer/base.html +++ b/monitoring/mock_uss/templates/tracer/base.html @@ -10,7 +10,7 @@
- Tracer: + Tracer ({{ username }}): Home (logs) Observation Areas
diff --git a/monitoring/mock_uss/templates/tracer/logs.html b/monitoring/mock_uss/templates/tracer/logs.html index b01ac40b45..e1b2e15f21 100644 --- a/monitoring/mock_uss/templates/tracer/logs.html +++ b/monitoring/mock_uss/templates/tracer/logs.html @@ -12,4 +12,34 @@ {% endfor %} +{% if is_admin %} +

+ Download logs +

+ +

+ Clear logs +

+{% endif %} {% endblock %} diff --git a/monitoring/mock_uss/templates/tracer/observation_areas_ui.html b/monitoring/mock_uss/templates/tracer/observation_areas_ui.html index 58a6ec0551..5c9be9ec79 100644 --- a/monitoring/mock_uss/templates/tracer/observation_areas_ui.html +++ b/monitoring/mock_uss/templates/tracer/observation_areas_ui.html @@ -40,8 +40,8 @@ let ridVersion = document.getElementById("rid_version").value; if (ridVersion != "None") { - let pollRID = document.getElementById("poll_f3411").value; - let subscribeRID = document.getElementById("subscribe_f3411").value; + let pollRID = document.getElementById("poll_f3411").checked; + let subscribeRID = document.getElementById("subscribe_f3411").checked; obsArea["f3411"] = { "rid_version": ridVersion, "poll": pollRID, @@ -51,10 +51,10 @@ let scdVersion = document.getElementById("scd_version").value; if (scdVersion != "None") { - let pollF3548 = document.getElementById("poll_f3548").value; - let subscribeF3548 = document.getElementById("subscribe_f3548").value; - let opIntents = document.getElementById("op_intents").value; - let constraints = document.getElementById("constraints").value; + let pollF3548 = document.getElementById("poll_f3548").checked; + let subscribeF3548 = document.getElementById("subscribe_f3548").checked; + let opIntents = document.getElementById("op_intents").checked; + let constraints = document.getElementById("constraints").checked; obsArea["f3548"] = { "poll": pollF3548, "subscribe": subscribeF3548, diff --git a/monitoring/mock_uss/tracer/observation_area_operations.py b/monitoring/mock_uss/tracer/observation_area_operations.py index f619545c3e..a7b44ef4ed 100644 --- a/monitoring/mock_uss/tracer/observation_area_operations.py +++ b/monitoring/mock_uss/tracer/observation_area_operations.py @@ -96,11 +96,10 @@ def delete_observation_area(area: ObservationArea) -> ObservationArea: rid_client=rid_client, ) - if area.f3548 is not None: + if area.f3548 is not None and area.f3548.subscription_id: scd_client = context.get_client(area.f3548.auth_spec, area.f3548.dss_base_url) - if area.f3548.subscription_id: - unsubscribe_scd( - subscription_id=area.f3548.subscription_id, scd_client=scd_client - ) + unsubscribe_scd( + subscription_id=area.f3548.subscription_id, scd_client=scd_client + ) return area diff --git a/monitoring/mock_uss/tracer/observation_areas.py b/monitoring/mock_uss/tracer/observation_areas.py index 899bb5314d..46a63c9055 100644 --- a/monitoring/mock_uss/tracer/observation_areas.py +++ b/monitoring/mock_uss/tracer/observation_areas.py @@ -47,7 +47,7 @@ class F3548ObservationArea(ImplicitDict): poll: bool """This area observes by periodically polling for information.""" - subscription_id: Optional[str] + subscription_id: Optional[str] = None """The F3548 subscription ID established to provide observation via notifications.""" diff --git a/monitoring/mock_uss/tracer/routes/observation_areas.py b/monitoring/mock_uss/tracer/routes/observation_areas.py index d01a4a1795..f430299f9d 100644 --- a/monitoring/mock_uss/tracer/routes/observation_areas.py +++ b/monitoring/mock_uss/tracer/routes/observation_areas.py @@ -25,6 +25,7 @@ F3411ObservationArea, ) from monitoring.mock_uss.tracer.tracer_poll import TASK_POLL_OBSERVATION_AREAS +from monitoring.mock_uss.tracer.ui_auth import ui_auth from monitoring.monitorlib import fetch import monitoring.monitorlib.fetch.rid from monitoring.monitorlib.geo import Volume3D @@ -32,6 +33,7 @@ @webapp.route("/tracer/observation_areas", methods=["GET"]) +@ui_auth.login_required def tracer_list_observation_areas() -> Tuple[str, int]: with db as tx: result = ListObservationAreasResponse( @@ -41,6 +43,7 @@ def tracer_list_observation_areas() -> Tuple[str, int]: @webapp.route("/tracer/observation_areas/", methods=["PUT"]) +@ui_auth.login_required(role="admin") def tracer_upsert_observation_area(area_id: str) -> Tuple[str, int]: try: req_body = flask.request.json @@ -80,6 +83,7 @@ def tracer_upsert_observation_area(area_id: str) -> Tuple[str, int]: @webapp.route("/tracer/observation_areas/", methods=["DELETE"]) +@ui_auth.login_required(role="admin") def tracer_delete_observation_area(area_id: str) -> Tuple[str, int]: with db as tx: if area_id not in tx.observation_areas: @@ -96,6 +100,7 @@ def tracer_delete_observation_area(area_id: str) -> Tuple[str, int]: @webapp.route("/tracer/observation_areas/import_requests", methods=["POST"]) +@ui_auth.login_required(role="admin") def tracer_import_observation_areas() -> Tuple[str, int]: try: req_body = flask.request.json diff --git a/monitoring/mock_uss/tracer/routes/ui.py b/monitoring/mock_uss/tracer/routes/ui.py index 2c8ee2eda3..ee549b6e99 100644 --- a/monitoring/mock_uss/tracer/routes/ui.py +++ b/monitoring/mock_uss/tracer/routes/ui.py @@ -1,5 +1,8 @@ +import datetime import glob +import io import os +import zipfile import arrow import flask @@ -11,13 +14,15 @@ from monitoring.mock_uss.tracer import context from monitoring.mock_uss.tracer.database import db from monitoring.mock_uss.tracer.observation_areas import ObservationArea +from monitoring.mock_uss.tracer.ui_auth import ui_auth from monitoring.monitorlib import fetch, geo, infrastructure from monitoring.monitorlib.fetch import summarize import monitoring.monitorlib.fetch.rid import monitoring.monitorlib.fetch.scd -@webapp.route("/tracer/logs") +@webapp.route("/tracer/logs", methods=["GET"]) +@ui_auth.login_required def tracer_list_logs(): logger.debug(f"Handling tracer_list_logs from {os.getpid()}") logs = [ @@ -31,13 +36,56 @@ def tracer_list_logs(): if os.path.exists(os.path.join(context.tracer_logger.log_path, kml)): kmls[log] = kml response = flask.make_response( - flask.render_template("tracer/logs.html", logs=logs, kmls=kmls) + flask.render_template( + "tracer/logs.html", + logs=logs, + kmls=kmls, + username=ui_auth.current_user().username, + is_admin=ui_auth.current_user().is_admin(), + ) ) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" return response +@webapp.route("/tracer/logs.zip") +@ui_auth.login_required(role="admin") +def tracer_download_logs(): + logs = [ + log + for log in reversed(sorted(os.listdir(context.tracer_logger.log_path))) + if log.endswith(".yaml") + ] + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: + for log in logs: + with open(os.path.join(context.tracer_logger.log_path, log), "r") as f: + zip_file.writestr(log, f.read()) + zip_name = f"logs_{datetime.datetime.utcnow().isoformat().split('.')[0]}.zip" + return flask.Response( + zip_buffer.getvalue(), + mimetype="application/zip", + headers={"Content-Disposition": f"attachment;filename={zip_name}"}, + ) + + +@webapp.route("/tracer/logs", methods=["DELETE"]) +@ui_auth.login_required(role="admin") +def tracer_clear_logs(): + if db.value.observation_areas: + return "Logs cannot be cleared while any observation areas exist", 400 + + files = [ + f + for f in reversed(sorted(os.listdir(context.tracer_logger.log_path))) + if f.endswith(".yaml") or f.endswith(".kml") + ] + for f in files: + os.remove(os.path.join(context.tracer_logger.log_path, f)) + return f"{len(files)} log files cleared successfully", 200 + + def _redact_and_augment_log(obj): if isinstance(obj, dict): result = {} @@ -59,6 +107,7 @@ def _redact_and_augment_log(obj): @webapp.route("/tracer/logs/") +@ui_auth.login_required def tracer_logs(log): logger.debug(f"Handling tracer_logs from {os.getpid()}") logfile = os.path.join(context.tracer_logger.log_path, log) @@ -96,10 +145,12 @@ def tracer_logs(log): "tracer/log.html", log=_redact_and_augment_log(obj), title=logfile, + username=ui_auth.current_user().username, ) @webapp.route("/tracer/kml/now.kml") +@ui_auth.login_required def tracer_kml_now(): logger.debug(f"Handling tracer_kml_now from {os.getpid()}") all_kmls = glob.glob(os.path.join(context.tracer_logger.log_path, "kml", "*.kml")) @@ -115,6 +166,7 @@ def tracer_kml_now(): @webapp.route("/tracer/kml/") +@ui_auth.login_required def tracer_kmls(kml): logger.debug(f"Handling tracer_kmls from {os.getpid()}") kmlfile = os.path.join(context.tracer_logger.log_path, "kml", kml) @@ -137,6 +189,7 @@ def _get_validated_obs_area(observation_area_id: str) -> ObservationArea: @webapp.route("/tracer/observation_areas//ui", methods=["GET"]) +@ui_auth.login_required(role="admin") def tracer_observation_area_ui(observation_area_id: str): logger.debug(f"Handling tracer_observation_area_ui from {os.getpid()}") area = _get_validated_obs_area(observation_area_id) @@ -162,6 +215,7 @@ def tracer_observation_area_ui(observation_area_id: str): alt_lo=alt_lo, alt_hi=alt_hi, now=StringBasedDateTime(arrow.utcnow().datetime), + username=ui_auth.current_user().username, ) @@ -169,6 +223,7 @@ def tracer_observation_area_ui(observation_area_id: str): "/tracer/observation_areas//rid_poll_requests", methods=["POST"], ) +@ui_auth.login_required(role="admin") def tracer_rid_request_poll(observation_area_id: str): logger.debug(f"Handling tracer_rid_request_poll from {os.getpid()}") area = _get_validated_obs_area(observation_area_id) @@ -194,8 +249,10 @@ def tracer_rid_request_poll(observation_area_id: str): @webapp.route("/tracer/observation_areas/ui", methods=["GET"]) +@ui_auth.login_required def tracer_observation_areas_ui(): return flask.render_template( "tracer/observation_areas_ui.html", title="Observation Areas UI", + username=ui_auth.current_user().username, ) diff --git a/monitoring/mock_uss/tracer/tracerlog.py b/monitoring/mock_uss/tracer/tracerlog.py index 8693002369..60a7004bf6 100644 --- a/monitoring/mock_uss/tracer/tracerlog.py +++ b/monitoring/mock_uss/tracer/tracerlog.py @@ -1,5 +1,5 @@ -import copy import datetime +import json import os from typing import Dict @@ -31,7 +31,7 @@ def log_new(self, code: str, content: Dict) -> str: logname = "{}.yaml".format(basename) fullname = os.path.join(self.log_path, logname) - dump = copy.deepcopy(content) + dump = json.loads(json.dumps(content)) dump["object_type"] = type(content).__name__ with open(fullname, "w") as f: f.write(yaml.dump(dump, indent=2)) diff --git a/monitoring/mock_uss/tracer/ui_auth.py b/monitoring/mock_uss/tracer/ui_auth.py new file mode 100644 index 0000000000..eedc382513 --- /dev/null +++ b/monitoring/mock_uss/tracer/ui_auth.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import List, Union + +from flask_httpauth import HTTPBasicAuth +from werkzeug.security import generate_password_hash, check_password_hash + +from monitoring.mock_uss import import_environment_variable, webapp + +ui_auth = HTTPBasicAuth() + +KEY_TRACER_UI_USERS = "MOCK_USS_TRACER_UI_USERS" +"""Environment variable containing configuration of users allowed to access the UI. + +Form: {USER1}[@{ROLE1}[,{ROLE2}[,{ROLE3}]]]={PASSWORD1};{USER2}[@{ROLE1}[,{ROLE2}[,{ROLE3}]]]={PASSWORD2} + +Example: admin@admin=admin;user1@viewer=avalidpassword;user2@viewer=anotherpassword +""" + +import_environment_variable(KEY_TRACER_UI_USERS, required=False, default="") + + +@dataclass +class User(object): + username: str + password_hash: str + roles: List[str] + + def is_admin(self) -> bool: + return "admin" in self.roles + + +def _get_users() -> List[User]: + users = [] + user_strings = webapp.config.get(KEY_TRACER_UI_USERS).split(";") + for user_string in user_strings: + if not user_string.strip(): + continue + if "=" not in user_string: + raise ValueError(f"Invalid tracer UI user string provided: `{user_string}`") + name_and_roles, password = user_string.split("=") + if "@" in name_and_roles: + name, roles_string = name_and_roles.split("@") + roles = [r.strip() for r in roles_string.split(",")] + else: + name = name_and_roles + roles = [] + password_hash = generate_password_hash(password.strip()) + users.append(User(username=name, password_hash=password_hash, roles=roles)) + return users + + +@ui_auth.verify_password +def verify_password(username: str, password: str) -> Union[bool, User]: + user = [u for u in _get_users() if u.username == username] + if not user: + return False # No matching user + if check_password_hash(user[0].password_hash, password): + return user[0] + else: + return False # Matching user, but wrong password + + +@ui_auth.get_user_roles +def get_user_roles(user: User) -> List[str]: + return user.roles diff --git a/requirements.txt b/requirements.txt index 1394fd9004..cdec74764d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ bc-jsonpath-ng==1.5.9 # uss_qualifier cryptography==42.0.4 faker===8.1.0 # uss_qualifier flask==2.3.3 +Flask-HTTPAuth==4.8.0 # mock_uss tracer geojson===2.5.0 # uss_qualifier gevent==22.10.2 # mock_uss / gunicorn worker google-auth==1.6.3