Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_REPORT_ROOT = ROOT / "reports" / "1c-access"
|
||||
|
||||
|
||||
def slugify(value: str, *, max_length: int = 80) -> str:
|
||||
slug = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._-]+", "-", value.strip())
|
||||
slug = re.sub(r"-+", "-", slug).strip("-._")
|
||||
return (slug or "role-audit")[:max_length]
|
||||
|
||||
|
||||
def esc(value: Any) -> str:
|
||||
return html.escape("" if value is None else str(value), quote=True)
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"JSON root is not an object: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_export(path: Path) -> dict[str, Any]:
|
||||
data = read_json(path)
|
||||
artifacts = data.get("artifacts") if isinstance(data.get("artifacts"), dict) else {}
|
||||
export_path = artifacts.get("json")
|
||||
if export_path:
|
||||
return read_json(Path(export_path))
|
||||
return data
|
||||
|
||||
|
||||
def summary_role(summary: dict[str, Any]) -> str | None:
|
||||
query = summary.get("query") if isinstance(summary.get("query"), dict) else {}
|
||||
role = query.get("role") if isinstance(query, dict) else None
|
||||
return str(role) if role is not None else None
|
||||
|
||||
|
||||
def find_latest_summaries(report_root: Path, base_id: str, *, role: str | None = None, count: int = 2) -> list[Path]:
|
||||
folder = report_root / slugify(base_id, max_length=60)
|
||||
summaries: list[tuple[str, Path]] = []
|
||||
role_filter = role.casefold() if role else None
|
||||
for path in folder.glob("*.summary.json"):
|
||||
try:
|
||||
summary = read_json(path)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
summary_role_value = summary_role(summary)
|
||||
if role_filter is not None and (summary_role_value or "").casefold() != role_filter:
|
||||
continue
|
||||
generated_at = str(summary.get("generated_at") or "")
|
||||
summaries.append((generated_at, path))
|
||||
summaries.sort(key=lambda item: item[0], reverse=True)
|
||||
return [path for _, path in summaries[:count]]
|
||||
|
||||
|
||||
def user_key(row: dict[str, Any]) -> str:
|
||||
value = row.get("user_id") or row.get("user_name")
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def row_user(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": row.get("user_id"),
|
||||
"user_name": row.get("user_name"),
|
||||
"user_type": row.get("user_type"),
|
||||
"user_active": row.get("user_active"),
|
||||
"user_marked": row.get("user_marked"),
|
||||
}
|
||||
|
||||
|
||||
def group_rows_by_user(export: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
users: dict[str, dict[str, Any]] = {}
|
||||
rows = export.get("rows") if isinstance(export.get("rows"), list) else []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
key = user_key(row)
|
||||
if not key:
|
||||
continue
|
||||
item = users.setdefault(key, {"user": row_user(row), "access_paths": set(), "rows": []})
|
||||
if row.get("access_path"):
|
||||
item["access_paths"].add(str(row.get("access_path")))
|
||||
item["rows"].append(row)
|
||||
for item in users.values():
|
||||
item["access_paths"] = sorted(item["access_paths"])
|
||||
return users
|
||||
|
||||
|
||||
def compare_exports(old_export: dict[str, Any], new_export: dict[str, Any]) -> dict[str, Any]:
|
||||
old_users = group_rows_by_user(old_export)
|
||||
new_users = group_rows_by_user(new_export)
|
||||
old_keys = set(old_users)
|
||||
new_keys = set(new_users)
|
||||
added_keys = sorted(new_keys - old_keys)
|
||||
removed_keys = sorted(old_keys - new_keys)
|
||||
common_keys = sorted(old_keys & new_keys)
|
||||
changed_paths = [
|
||||
{
|
||||
"user": new_users[key]["user"],
|
||||
"old_access_paths": old_users[key]["access_paths"],
|
||||
"new_access_paths": new_users[key]["access_paths"],
|
||||
}
|
||||
for key in common_keys
|
||||
if old_users[key]["access_paths"] != new_users[key]["access_paths"]
|
||||
]
|
||||
return {
|
||||
"schema": "onec_access_role_audit_compare.v1",
|
||||
"status": "ok",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"counts": {
|
||||
"old_users": len(old_keys),
|
||||
"new_users": len(new_keys),
|
||||
"added_users": len(added_keys),
|
||||
"removed_users": len(removed_keys),
|
||||
"unchanged_users": len(common_keys),
|
||||
"changed_access_paths": len(changed_paths),
|
||||
},
|
||||
"added_users": [new_users[key]["user"] for key in added_keys],
|
||||
"removed_users": [old_users[key]["user"] for key in removed_keys],
|
||||
"changed_access_paths": changed_paths,
|
||||
}
|
||||
|
||||
|
||||
def render_html_report(compare: dict[str, Any]) -> str:
|
||||
counts = compare.get("counts") if isinstance(compare.get("counts"), dict) else {}
|
||||
|
||||
def user_rows(name: str) -> str:
|
||||
users = compare.get(name) if isinstance(compare.get(name), list) else []
|
||||
return "\n".join(
|
||||
"<tr>"
|
||||
f"<td>{esc(item.get('user_name'))}</td>"
|
||||
f"<td>{esc(item.get('user_id'))}</td>"
|
||||
f"<td>{esc(item.get('user_type'))}</td>"
|
||||
f"<td>{esc(item.get('user_active'))}</td>"
|
||||
f"<td>{esc(item.get('user_marked'))}</td>"
|
||||
"</tr>"
|
||||
for item in users
|
||||
if isinstance(item, dict)
|
||||
) or "<tr><td colspan=\"5\">Absent.</td></tr>"
|
||||
|
||||
changed = compare.get("changed_access_paths") if isinstance(compare.get("changed_access_paths"), list) else []
|
||||
changed_rows = "\n".join(
|
||||
"<tr>"
|
||||
f"<td>{esc((item.get('user') or {}).get('user_name') if isinstance(item.get('user'), dict) else None)}</td>"
|
||||
f"<td><pre>{esc('\\n'.join(item.get('old_access_paths') or []))}</pre></td>"
|
||||
f"<td><pre>{esc('\\n'.join(item.get('new_access_paths') or []))}</pre></td>"
|
||||
"</tr>"
|
||||
for item in changed
|
||||
if isinstance(item, dict)
|
||||
) or "<tr><td colspan=\"3\">Absent.</td></tr>"
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>1C Access Audit Compare</title>
|
||||
<style>
|
||||
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2933; }}
|
||||
.cards {{ display: flex; flex-wrap: wrap; gap: 12px; margin: 18px 0; }}
|
||||
.card {{ border: 1px solid #d9e2ec; border-radius: 8px; padding: 12px 14px; min-width: 130px; }}
|
||||
.card b {{ display: block; font-size: 20px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin: 12px 0 24px; }}
|
||||
th, td {{ border: 1px solid #d9e2ec; padding: 8px; vertical-align: top; }}
|
||||
th {{ background: #f0f4f8; text-align: left; }}
|
||||
pre {{ white-space: pre-wrap; margin: 0; font-family: Consolas, monospace; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>1C Access Audit Compare</h1>
|
||||
<div>Generated: {esc(compare.get('generated_at'))}</div>
|
||||
<div class="cards">
|
||||
<div class="card"><b>{esc(counts.get('old_users'))}</b>old users</div>
|
||||
<div class="card"><b>{esc(counts.get('new_users'))}</b>new users</div>
|
||||
<div class="card"><b>{esc(counts.get('added_users'))}</b>added</div>
|
||||
<div class="card"><b>{esc(counts.get('removed_users'))}</b>removed</div>
|
||||
<div class="card"><b>{esc(counts.get('changed_access_paths'))}</b>path changes</div>
|
||||
</div>
|
||||
<h2>Added users</h2>
|
||||
<table><thead><tr><th>User</th><th>ID</th><th>Type</th><th>Active</th><th>Marked</th></tr></thead><tbody>{user_rows('added_users')}</tbody></table>
|
||||
<h2>Removed users</h2>
|
||||
<table><thead><tr><th>User</th><th>ID</th><th>Type</th><th>Active</th><th>Marked</th></tr></thead><tbody>{user_rows('removed_users')}</tbody></table>
|
||||
<h2>Changed access paths</h2>
|
||||
<table><thead><tr><th>User</th><th>Old paths</th><th>New paths</th></tr></thead><tbody>{changed_rows}</tbody></table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def compare_files(old_path: Path, new_path: Path, *, output: Path | None = None, html_output: Path | None = None) -> dict[str, Any]:
|
||||
result = compare_exports(load_export(old_path), load_export(new_path))
|
||||
result["sources"] = {"old": str(old_path), "new": str(new_path)}
|
||||
result["artifacts"] = {
|
||||
**({"json": str(output)} if output is not None else {}),
|
||||
**({"html": str(html_output)} if html_output is not None else {}),
|
||||
}
|
||||
if output is not None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if html_output is not None:
|
||||
html_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
html_output.write_text(render_html_report(result), encoding="utf-8")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Compare two 1C access role audit exports or summaries.")
|
||||
parser.add_argument("old", type=Path, nargs="?")
|
||||
parser.add_argument("new", type=Path, nargs="?")
|
||||
parser.add_argument("--latest", action="store_true", help="Compare the latest two summaries for a base/role.")
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--role")
|
||||
parser.add_argument("--report-root", type=Path, default=DEFAULT_REPORT_ROOT)
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--html", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
old_path = args.old
|
||||
new_path = args.new
|
||||
output = args.output
|
||||
html_output = args.html
|
||||
if args.latest:
|
||||
latest = find_latest_summaries(args.report_root, args.base_id, role=args.role, count=2)
|
||||
if len(latest) < 2:
|
||||
parser.error("Not enough matching summaries for --latest; need at least two.")
|
||||
new_path, old_path = latest[0], latest[1]
|
||||
folder = args.report_root / slugify(args.base_id, max_length=60)
|
||||
stem = f"compare-{old_path.stem.replace('.summary', '')}-{new_path.stem.replace('.summary', '')}"
|
||||
output = output or folder / f"{stem}.json"
|
||||
html_output = html_output or folder / f"{stem}.html"
|
||||
if old_path is None or new_path is None:
|
||||
parser.error("Either provide OLD and NEW paths or use --latest.")
|
||||
|
||||
result = compare_files(old_path, new_path, output=output, html_output=html_output)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("status") == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user