Files
llm/scripts/export_1c_access_role_audit.py
2026-08-14 09:40:51 +03:00

385 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import hashlib
import html
import json
import re
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_BASE_URL = "http://docker.cin.su:8011"
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 rpc(base_url: str, method: str, payload: dict[str, Any], *, timeout: int) -> dict[str, Any]:
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
base_url.rstrip("/") + "/rpc",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, dict):
return {"status": "error", "error": "response_not_object", "response": data}
return data
def write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def build_artifact_paths(report_root: Path, base_id: str, role: str, timestamp: str) -> dict[str, Path]:
digest = hashlib.sha1(role.encode("utf-8")).hexdigest()[:10]
stem = f"{timestamp}-{slugify(role)}-{digest}"
folder = report_root / slugify(base_id, max_length=60)
return {
"csv": folder / f"{stem}.csv",
"json": folder / f"{stem}.json",
"analysis": folder / f"{stem}.analysis.json",
"summary": folder / f"{stem}.summary.json",
"html": folder / f"{stem}.html",
"index": folder / "index.html",
}
def esc(value: Any) -> str:
return html.escape("" if value is None else str(value), quote=True)
def render_html_report(summary: dict[str, Any], export: dict[str, Any], analysis: dict[str, Any] | None) -> str:
rows = export.get("rows") if isinstance(export.get("rows"), list) else []
findings = summary.get("findings") if isinstance(summary.get("findings"), list) else []
counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
artifacts = summary.get("artifacts") if isinstance(summary.get("artifacts"), dict) else {}
risk = str(summary.get("risk_level") or "unknown")
role = ((summary.get("query") if isinstance(summary.get("query"), dict) else {}) or {}).get("role")
finding_items = "\n".join(
f"<li><strong>{esc(item.get('severity'))}: {esc(item.get('code'))}</strong><br>{esc(item.get('message'))}</li>"
for item in findings
if isinstance(item, dict)
) or "<li>Findings absent.</li>"
table_rows = "\n".join(
"<tr>"
f"<td>{esc(row.get('matched_role_name'))}</td>"
f"<td>{esc(row.get('profile_name'))}</td>"
f"<td>{esc(row.get('group_name'))}</td>"
f"<td>{esc(row.get('user_name'))}</td>"
f"<td>{esc(row.get('user_type'))}</td>"
f"<td>{esc(row.get('user_active'))}</td>"
f"<td>{esc(row.get('access_path'))}</td>"
"</tr>"
for row in rows
if isinstance(row, dict)
)
artifact_links = "\n".join(
f"<a href=\"{esc(Path(path).name)}\">{esc(name)}</a>"
for name, path in artifacts.items()
)
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>1C Access Role Audit</title>
<style>
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2933; }}
h1 {{ margin-bottom: 4px; }}
.meta, .artifacts {{ color: #52616b; margin: 8px 0 18px; }}
.badge {{ display: inline-block; padding: 4px 10px; border-radius: 999px; font-weight: 700; }}
.low {{ background: #e3f8e5; color: #176b32; }}
.medium {{ background: #fff2cc; color: #875a00; }}
.high {{ background: #ffe0e0; color: #9f1d1d; }}
.cards {{ display: flex; flex-wrap: wrap; gap: 12px; margin: 18px 0; }}
.card {{ border: 1px solid #d9e2ec; border-radius: 8px; padding: 12px 14px; min-width: 120px; }}
.card b {{ display: block; font-size: 20px; }}
input {{ width: 100%; max-width: 520px; padding: 9px 10px; margin: 12px 0; border: 1px solid #bcccdc; border-radius: 6px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
th, td {{ border: 1px solid #d9e2ec; padding: 8px; vertical-align: top; }}
th {{ background: #f0f4f8; text-align: left; position: sticky; top: 0; }}
tr:nth-child(even) {{ background: #fbfcfd; }}
.artifacts a {{ margin-right: 12px; }}
</style>
</head>
<body>
<h1>1C Access Role Audit</h1>
<div class="meta">Base: <b>{esc(summary.get('base_id'))}</b> | Role query: <b>{esc(role)}</b> | Generated: {esc(summary.get('generated_at'))}</div>
<div>Risk: <span class="badge {esc(risk)}">{esc(risk)}</span></div>
<div class="cards">
<div class="card"><b>{esc(counts.get('users'))}</b>users</div>
<div class="card"><b>{esc(counts.get('groups'))}</b>groups</div>
<div class="card"><b>{esc(counts.get('profiles'))}</b>profiles</div>
<div class="card"><b>{esc(counts.get('rows'))}</b>rows</div>
<div class="card"><b>{esc(len(findings))}</b>findings</div>
</div>
<h2>Findings</h2>
<ul>{finding_items}</ul>
<div class="artifacts">Artifacts: {artifact_links}</div>
<h2>Rows</h2>
<input id="filter" placeholder="Filter by user, group, profile, role..." oninput="filterRows()">
<table id="rows">
<thead><tr><th>Role</th><th>Profile</th><th>Group</th><th>User</th><th>Type</th><th>Active</th><th>Access path</th></tr></thead>
<tbody>{table_rows}</tbody>
</table>
<script>
function filterRows() {{
const q = document.getElementById('filter').value.toLowerCase();
document.querySelectorAll('#rows tbody tr').forEach(tr => {{
tr.style.display = tr.innerText.toLowerCase().includes(q) ? '' : 'none';
}});
}}
</script>
</body>
</html>
"""
def load_audit_summaries(folder: Path, *, limit: int = 100) -> list[dict[str, Any]]:
summaries: list[dict[str, Any]] = []
for path in folder.glob("*.summary.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(data, dict):
data.setdefault("summary_path", str(path))
summaries.append(data)
summaries.sort(key=lambda item: str(item.get("generated_at") or ""), reverse=True)
return summaries[:limit]
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 compare_filename_for_pair(old_summary: dict[str, Any], new_summary: dict[str, Any], *, suffix: str) -> str | None:
old_path = old_summary.get("summary_path")
new_path = new_summary.get("summary_path")
if not old_path or not new_path:
return None
old_stem = Path(str(old_path)).stem.replace(".summary", "")
new_stem = Path(str(new_path)).stem.replace(".summary", "")
return f"compare-{old_stem}-{new_stem}.{suffix}"
def render_index_html(base_id: str, summaries: list[dict[str, Any]]) -> str:
rows: list[str] = []
previous_by_role: dict[str, dict[str, Any]] = {}
for item in reversed(summaries):
role_key = (summary_role(item) or "").casefold()
item["previous_summary_path"] = previous_by_role.get(role_key, {}).get("summary_path")
previous_by_role[role_key] = item
for item in summaries:
query = item.get("query") if isinstance(item.get("query"), dict) else {}
counts = item.get("counts") if isinstance(item.get("counts"), dict) else {}
findings = item.get("findings") if isinstance(item.get("findings"), list) else []
artifacts = item.get("artifacts") if isinstance(item.get("artifacts"), dict) else {}
previous = next((candidate for candidate in summaries if candidate.get("summary_path") == item.get("previous_summary_path")), None)
compare_name = compare_filename_for_pair(previous, item, suffix="html") if isinstance(previous, dict) else None
compare_link = f"<a href=\"{esc(compare_name)}\">compare_previous</a>" if compare_name and (Path(str(item.get("summary_path"))).parent / compare_name).exists() else ""
links = " ".join(
f"<a href=\"{esc(Path(path).name)}\">{esc(name)}</a>"
for name, path in artifacts.items()
if name != "index"
) + (f" {compare_link}" if compare_link else "")
rows.append(
"<tr>"
f"<td>{esc(item.get('generated_at'))}</td>"
f"<td>{esc(query.get('role') if isinstance(query, dict) else None)}</td>"
f"<td><span class=\"badge {esc(item.get('risk_level') or 'unknown')}\">{esc(item.get('risk_level') or 'unknown')}</span></td>"
f"<td>{esc(counts.get('users'))}</td>"
f"<td>{esc(counts.get('rows'))}</td>"
f"<td>{esc(len(findings))}</td>"
f"<td>{links}</td>"
"</tr>"
)
table_rows = "\n".join(rows) or "<tr><td colspan=\"7\">Reports absent.</td></tr>"
generated_at = datetime.now(timezone.utc).isoformat()
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>1C Access Audit Index</title>
<style>
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2933; }}
h1 {{ margin-bottom: 4px; }}
.meta {{ color: #52616b; margin: 8px 0 18px; }}
input {{ width: 100%; max-width: 560px; padding: 9px 10px; margin: 12px 0; border: 1px solid #bcccdc; border-radius: 6px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
th, td {{ border: 1px solid #d9e2ec; padding: 8px; vertical-align: top; }}
th {{ background: #f0f4f8; text-align: left; position: sticky; top: 0; }}
tr:nth-child(even) {{ background: #fbfcfd; }}
a {{ margin-right: 10px; }}
.badge {{ display: inline-block; padding: 3px 9px; border-radius: 999px; font-weight: 700; }}
.low {{ background: #e3f8e5; color: #176b32; }}
.medium {{ background: #fff2cc; color: #875a00; }}
.high {{ background: #ffe0e0; color: #9f1d1d; }}
.unknown {{ background: #edf2f7; color: #52616b; }}
</style>
</head>
<body>
<h1>1C Access Audit Index</h1>
<div class="meta">Base: <b>{esc(base_id)}</b> | Reports: <b>{esc(len(summaries))}</b> | Updated: {esc(generated_at)}</div>
<input id="filter" placeholder="Filter by role, risk, artifact..." oninput="filterRows()">
<table id="reports">
<thead><tr><th>Generated</th><th>Role query</th><th>Risk</th><th>Users</th><th>Rows</th><th>Findings</th><th>Artifacts</th></tr></thead>
<tbody>{table_rows}</tbody>
</table>
<script>
function filterRows() {{
const q = document.getElementById('filter').value.toLowerCase();
document.querySelectorAll('#reports tbody tr').forEach(tr => {{
tr.style.display = tr.innerText.toLowerCase().includes(q) ? '' : 'none';
}});
}}
</script>
</body>
</html>
"""
def update_index(report_root: Path, base_id: str, *, limit: int = 100) -> Path:
folder = report_root / slugify(base_id, max_length=60)
folder.mkdir(parents=True, exist_ok=True)
index_path = folder / "index.html"
index_path.write_text(render_index_html(base_id, load_audit_summaries(folder, limit=limit)), encoding="utf-8")
return index_path
def export_role_audit(
*,
adapter_url: str,
base_id: str,
role: str,
report_root: Path,
timeout: int,
user_threshold: int,
include_analysis: bool,
limit: int,
include_html: bool = True,
) -> dict[str, Any]:
export = rpc(
adapter_url,
"access.role.audit_export",
{
"base_id": base_id,
"role": role,
"format": "csv",
"limit": limit,
"timeout_seconds": timeout,
},
timeout=timeout + 30,
)
analysis: dict[str, Any] | None = None
if include_analysis:
analysis = rpc(
adapter_url,
"access.role.audit_analyze",
{
"base_id": base_id,
"role": role,
"user_threshold": user_threshold,
"timeout_seconds": timeout,
},
timeout=timeout + 30,
)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
paths = build_artifact_paths(report_root, base_id, role, timestamp)
paths["csv"].parent.mkdir(parents=True, exist_ok=True)
paths["csv"].write_text(str(export.get("csv") or ""), encoding="utf-8-sig")
write_json(paths["json"], export)
if analysis is not None:
write_json(paths["analysis"], analysis)
summary = {
"schema": "onec_access_role_audit_artifact.v1",
"status": "ok" if export.get("status") == "ok" and (analysis is None or analysis.get("status") == "ok") else "error",
"base_id": base_id,
"query": {"role": role},
"generated_at": datetime.now(timezone.utc).isoformat(),
"adapter_url": adapter_url,
"artifacts": {key: str(path) for key, path in paths.items() if key != "analysis" or analysis is not None},
"counts": export.get("counts") if isinstance(export.get("counts"), dict) else {},
"risk_level": analysis.get("risk_level") if isinstance(analysis, dict) else None,
"findings": analysis.get("findings") if isinstance(analysis, dict) else [],
"export_status": export.get("status"),
"analysis_status": analysis.get("status") if isinstance(analysis, dict) else None,
}
if include_html:
paths["html"].write_text(render_html_report(summary, export, analysis), encoding="utf-8")
else:
summary["artifacts"].pop("html", None)
write_json(paths["summary"], summary)
update_index(report_root, base_id)
return summary
def main() -> int:
parser = argparse.ArgumentParser(description="Export 1C/BSP role access audit artifacts to the local workspace.")
parser.add_argument("--adapter-url", default=DEFAULT_BASE_URL)
parser.add_argument("--base-id", default="upo_test")
parser.add_argument("--role", required=True)
parser.add_argument("--report-root", type=Path, default=DEFAULT_REPORT_ROOT)
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--limit", type=int, default=20000)
parser.add_argument("--user-threshold", type=int, default=50)
parser.add_argument("--no-analysis", action="store_true")
parser.add_argument("--no-html", action="store_true")
parser.add_argument("--json", action="store_true", help="Print full artifact summary JSON.")
args = parser.parse_args()
summary = export_role_audit(
adapter_url=args.adapter_url,
base_id=args.base_id,
role=args.role,
report_root=args.report_root,
timeout=args.timeout,
user_threshold=args.user_threshold,
include_analysis=not args.no_analysis,
limit=args.limit,
include_html=not args.no_html,
)
if args.json:
print(json.dumps(summary, ensure_ascii=False, indent=2))
else:
print(
json.dumps(
{
"status": summary.get("status"),
"base_id": summary.get("base_id"),
"role": role_summary(summary),
"risk_level": summary.get("risk_level"),
"counts": summary.get("counts"),
"artifacts": summary.get("artifacts"),
},
ensure_ascii=False,
indent=2,
)
)
return 0 if summary.get("status") == "ok" else 1
def role_summary(summary: dict[str, Any]) -> str | None:
query = summary.get("query") if isinstance(summary.get("query"), dict) else {}
return query.get("role") if isinstance(query, dict) else None
if __name__ == "__main__":
raise SystemExit(main())