diff --git a/compliance-monitor/README.md b/compliance-monitor/README.md index 3a487ad7b..8549cc932 100644 --- a/compliance-monitor/README.md +++ b/compliance-monitor/README.md @@ -154,6 +154,9 @@ The return value is a _list of objects_ like the following: Supports query parameters: +- `subject=SUBJECT`: restrict results to this subject; if given, accounts without role `read_any` + may still access their own subject's results; if omitted, role `read_any` is required; +- `scopeuuid=SCOPEUUID`: restrict results to this scope; - `approved=APPROVED`: return only results with approval status `APPROVED` (either 0 or 1); default: no such restriction is applied; - `limit=N`: return at most N items (default: 10); diff --git a/compliance-monitor/monitor.py b/compliance-monitor/monitor.py index 9d759d2b0..8d6488fd9 100755 --- a/compliance-monitor/monitor.py +++ b/compliance-monitor/monitor.py @@ -42,7 +42,7 @@ db_get_keys, db_insert_report, db_get_recent_results2, db_patch_approval2, db_get_report, db_ensure_schema, db_get_apikeys, db_update_apikey, db_filter_apikeys, db_clear_delegates, db_find_subjects, db_insert_result2, db_get_relevant_results2, db_add_delegate, db_get_group, - db_filter_accounts, + db_filter_accounts, db_get_report_history, ) @@ -84,6 +84,7 @@ def __init__(self): ROLES = {'read_any': 1, 'append_any': 2, 'admin': 4, 'approve': 8} # number of days that expired results will be considered in lieu of more recent, but unapproved ones GRACE_PERIOD_DAYS = 7 +HISTORY_LIMIT = 500 # separator between signature and report data; use something like # ssh-keygen \ # -Y sign -f ~/.ssh/id_ed25519 -n report myreport.yaml @@ -122,7 +123,12 @@ class ViewType(Enum): ViewType.fragment: 'scope.md', ViewType.page: 'overview.html', } -REQUIRED_TEMPLATES = tuple(set(fn for view in (VIEW_REPORT, VIEW_DETAIL, VIEW_TABLE, VIEW_SCOPE) for fn in view.values())) +VIEW_HISTORY = { + ViewType.markdown: 'history.md', + ViewType.fragment: 'history.md', + ViewType.page: 'overview.html', +} +REQUIRED_TEMPLATES = tuple(set(fn for view in (VIEW_REPORT, VIEW_DETAIL, VIEW_TABLE, VIEW_SCOPE, VIEW_HISTORY) for fn in view.values())) # do I hate these globals, but I don't see another way with these frameworks @@ -580,8 +586,9 @@ def render_view(view, view_type, detail_page='detail', base_url='/', title=None, stage1 = view[ViewType.fragment] def scope_url(uuid): return f"{base_url}page/scope/{uuid}" # noqa: E306,E704 def detail_url(subject, scope): return f"{base_url}page/{detail_page}/{subject}/{scope}" # noqa: E306,E704 + def history_url(subject, scope): return f"{base_url}page/history/{subject}/{scope}" # noqa: E306,E704 def report_url(report, *args, **kwargs): return _build_report_url(base_url, report, *args, **kwargs) # noqa: E306,E704 - fragment = templates_map[stage1].render(base_url=base_url, detail_url=detail_url, report_url=report_url, scope_url=scope_url, **kwargs) + fragment = templates_map[stage1].render(base_url=base_url, detail_url=detail_url, history_url=history_url, report_url=report_url, scope_url=scope_url, **kwargs) if view_type != ViewType.markdown and stage1.endswith('.md'): fragment = markdown(fragment, extensions=['extra']) if stage1 != stage2: @@ -702,6 +709,24 @@ async def get_detail_full( ) +@app.get("/{view_type}/history/{subject}/{scopeuuid}") +async def get_history( + request: Request, + conn: Annotated[connection, Depends(get_conn)], + view_type: ViewType, + subject: str, + scopeuuid: str, +): + scopes = get_scopes() + scope_name = scopes[scopeuuid]['name'] if scopeuuid in scopes else scopeuuid + with conn.cursor() as cur: + history = db_get_report_history(cur, subject, scopeuuid, limit=HISTORY_LIMIT) + return render_view( + VIEW_HISTORY, view_type, history=history, subject=subject, scope_name=scope_name, + history_limit=HISTORY_LIMIT, base_url=settings.base_url, title=f'📜 Report history for {subject}', + ) + + @app.get("/{view_type}/table") async def get_table( request: Request, @@ -774,11 +799,16 @@ async def get_results( account: Annotated[tuple[str, str], Depends(auth)], conn: Annotated[connection, Depends(get_conn)], approved: Optional[bool] = None, limit: int = 10, skip: int = 0, + subject: Optional[str] = None, scopeuuid: Optional[str] = None, ): - """get recent results, potentially filtered by approval status""" - check_role(account, roles=ROLES['read_any']) + """get recent results, optionally filtered by subject, scope, and approval status""" + if subject is None: + check_role(account, roles=ROLES['read_any']) + else: + check_role(account, subject, ROLES['read_any']) with conn.cursor() as cur: - return db_get_recent_results2(cur, approved, limit, skip, max_age_days=GRACE_PERIOD_DAYS) + return db_get_recent_results2(cur, approved, limit, skip, max_age_days=GRACE_PERIOD_DAYS, + subject=subject, scopeuuid=scopeuuid) @app.post("/results") diff --git a/compliance-monitor/sql.py b/compliance-monitor/sql.py index 1c8f9b07c..ff08f35ee 100644 --- a/compliance-monitor/sql.py +++ b/compliance-monitor/sql.py @@ -194,7 +194,7 @@ def db_upgrade_schema(conn: connection, cur: cursor): # that way just in case we want to use another database at some point while True: current = db_get_schema_version(cur) - if current >= SCHEMA_VERSIONS[-1]: # bail if version is too new (but hope it's compatible) + if current is not None and current >= SCHEMA_VERSIONS[-1]: # bail if version is too new (but hope it's compatible) break if current is None: # this is an empty db, but it also used to be the case with v1 @@ -398,7 +398,7 @@ def db_get_relevant_results2( return cur.fetchall() -def db_get_recent_results2(cur: cursor, approved, limit, skip, max_age_days=None): +def db_get_recent_results2(cur: cursor, approved, limit, skip, max_age_days=None, subject=None, scopeuuid=None): """list recent test results without grouping by scope/version/check""" columns = ('reportuuid', 'subject', 'checked_at', 'scopeuuid', 'version', 'check', 'result', 'approval') cur.execute(sql.SQL(''' @@ -414,8 +414,29 @@ def db_get_recent_results2(cur: cursor, approved, limit, skip, max_age_days=None f"checked_at > NOW() - interval '{max_age_days:d} days'" ), None if approved is None else sql.SQL('approval = %(approved)s'), + None if subject is None else sql.SQL('result2.subject = %(subject)s'), + None if scopeuuid is None else sql.SQL('result2.scopeuuid = %(scopeuuid)s'), ), - ), {"limit": limit, "skip": skip, "approved": approved}) + ), {"limit": limit, "skip": skip, "approved": approved, "subject": subject, "scopeuuid": scopeuuid}) + return [{col: val for col, val in zip(columns, row)} for row in cur.fetchall()] + + +def db_get_report_history(cur: cursor, subject, scopeuuid, limit=500): + """list all past reports for a subject/scope, newest first, with pass/fail/abort counts""" + cur.execute(''' + SELECT report.reportuuid, report.checked_at, + COUNT(*) FILTER (WHERE result2.result = 1) AS pass_count, + COUNT(*) FILTER (WHERE result2.result = -1) AS fail_count, + COUNT(*) FILTER (WHERE result2.result = 0) AS abort_count + FROM report + JOIN result2 ON result2.reportid = report.reportid + WHERE report.subject = %(subject)s + AND result2.scopeuuid = %(scopeuuid)s + GROUP BY report.reportuuid, report.checked_at + ORDER BY report.checked_at DESC + LIMIT %(limit)s; + ''', {"subject": subject, "scopeuuid": scopeuuid, "limit": limit}) + columns = ('reportuuid', 'checked_at', 'pass_count', 'fail_count', 'abort_count') return [{col: val for col, val in zip(columns, row)} for row in cur.fetchall()] diff --git a/compliance-monitor/templates/details.md.j2 b/compliance-monitor/templates/details.md.j2 index a25969526..53fda8da6 100644 --- a/compliance-monitor/templates/details.md.j2 +++ b/compliance-monitor/templates/details.md.j2 @@ -22,6 +22,7 @@ Jump to ## {{ subject }}: {{ scope_result.name }} - [spec overview]({{ scope_url(scopeuuid) }}) +- [📜 report history]({{ history_url(subject, scopeuuid) }}) {% if not scope_result.relevant -%} diff --git a/compliance-monitor/templates/history.md.j2 b/compliance-monitor/templates/history.md.j2 new file mode 100644 index 000000000..9789b01a4 --- /dev/null +++ b/compliance-monitor/templates/history.md.j2 @@ -0,0 +1,14 @@ +## 📜 Report history: {{ subject }} — {{ scope_name }} + +{% if history %} +| Date | PASS | FAIL | ABORT | Report | +|---|---|---|---|---| +{% for entry in history -%} +| {{ entry.checked_at | short_isodate }} | {{ entry.pass_count }} | {{ entry.fail_count }} | {{ entry.abort_count }} | [view]({{ report_url(entry.reportuuid) }}) | +{% endfor %} +{% if history | length == history_limit %} +_Showing the most recent {{ history_limit }} reports. Older entries are not displayed._ +{% endif %} +{% else %} +No history available. +{% endif %} diff --git a/compliance-monitor/templates/report.md.j2 b/compliance-monitor/templates/report.md.j2 index 5b27fabe4..7dc04b0fa 100644 --- a/compliance-monitor/templates/report.md.j2 +++ b/compliance-monitor/templates/report.md.j2 @@ -3,6 +3,7 @@ - uuid: [{{ report.run.uuid }}]({{ report_url(report.run.uuid, download=True) }}) - subject: {{ report.subject }} - scope: [{{ report.spec.name }}]({{ scope_url(report.spec.uuid) }}) +- [📜 report history]({{ history_url(report.subject, report.spec.uuid) }}) - checked at: {{ report.checked_at }} - variable assignment: {% set comma = joiner(", ") %}{% for key, value in report.run.assignment.items() -%}{{comma()}}`{{ key }}`=`{{ value }}`{% endfor %}