Skip to content

Commit

Permalink
[mock_uss/tracer] Add auth and log management to tracer (#589)
Browse files Browse the repository at this point in the history
* Add auth to tracer

* Add log management

* Address comments
  • Loading branch information
BenjaminPelletier authored Mar 24, 2024
1 parent c9abd19 commit 2f1179b
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 17 deletions.
1 change: 1 addition & 0 deletions monitoring/mock_uss/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion monitoring/mock_uss/templates/tracer/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</head>
<body>
<div>
Tracer:
Tracer ({{ username }}):
<a href="{{ url_for('tracer_list_logs') }}">Home (logs)</a>
<a href="{{ url_for('tracer_observation_areas_ui') }}">Observation Areas</a>
</div>
Expand Down
30 changes: 30 additions & 0 deletions monitoring/mock_uss/templates/tracer/logs.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,34 @@
</li>
{% endfor %}
</ul>
{% if is_admin %}
<p>
<a href="{{ url_for('tracer_download_logs') }}">Download logs</a>
</p>
<script>
function clearAllLogs() {
let random_chars = (Math.random() + 1).toString(36).slice(2, 7);
let response = prompt("Are you sure you want to delete all log files? Enter " + random_chars + " below to confirm.");
if (response == random_chars) {
let xhr = new XMLHttpRequest();
let url = "{{ url_for('tracer_clear_logs') }}";
xhr.open("DELETE", url);
xhr.onreadystatechange = function () {
if (this.readyState == XMLHttpRequest.DONE) {
if (this.status == 200) {
location.reload();
} else {
alert("Error clearing logs (see also network log in debugger):\n" + this.response);
}
}
}
xhr.send();
}
return false;
}
</script>
<p>
<a href="#" onclick="clearAllLogs()">Clear logs</a>
</p>
{% endif %}
{% endblock %}
12 changes: 6 additions & 6 deletions monitoring/mock_uss/templates/tracer/observation_areas_ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions monitoring/mock_uss/tracer/observation_area_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion monitoring/mock_uss/tracer/observation_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""


Expand Down
5 changes: 5 additions & 0 deletions monitoring/mock_uss/tracer/routes/observation_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
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
from monitoring.monitorlib.geotemporal import Volume4D


@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(
Expand All @@ -41,6 +43,7 @@ def tracer_list_observation_areas() -> Tuple[str, int]:


@webapp.route("/tracer/observation_areas/<area_id>", 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
Expand Down Expand Up @@ -80,6 +83,7 @@ def tracer_upsert_observation_area(area_id: str) -> Tuple[str, int]:


@webapp.route("/tracer/observation_areas/<area_id>", 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:
Expand All @@ -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
Expand Down
61 changes: 59 additions & 2 deletions monitoring/mock_uss/tracer/routes/ui.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import datetime
import glob
import io
import os
import zipfile

import arrow
import flask
Expand All @@ -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 = [
Expand All @@ -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 = {}
Expand All @@ -59,6 +107,7 @@ def _redact_and_augment_log(obj):


@webapp.route("/tracer/logs/<log>")
@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)
Expand Down Expand Up @@ -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"))
Expand All @@ -115,6 +166,7 @@ def tracer_kml_now():


@webapp.route("/tracer/kml/<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)
Expand All @@ -137,6 +189,7 @@ def _get_validated_obs_area(observation_area_id: str) -> ObservationArea:


@webapp.route("/tracer/observation_areas/<observation_area_id>/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)
Expand All @@ -162,13 +215,15 @@ 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,
)


@webapp.route(
"/tracer/observation_areas/<observation_area_id>/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)
Expand All @@ -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,
)
4 changes: 2 additions & 2 deletions monitoring/mock_uss/tracer/tracerlog.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import copy
import datetime
import json
import os
from typing import Dict

Expand Down Expand Up @@ -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))
Expand Down
65 changes: 65 additions & 0 deletions monitoring/mock_uss/tracer/ui_auth.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2f1179b

Please sign in to comment.