Add CW security roles reference docs and PSA ticket management plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""
|
|
Generate ConnectWise security-role documentation from the source XLSX.
|
|
|
|
Produces:
|
|
- api-member-security-roles.yaml : machine-readable source of truth
|
|
- api-member-security-roles.md : human-readable reference
|
|
|
|
Re-run this script after editing the source XLSX. Both outputs are
|
|
deterministic — they will produce identical content from identical input,
|
|
so diffs in version control reflect only real permission-model changes.
|
|
|
|
Usage:
|
|
python generate_role_docs.py \
|
|
--source source/Security_Roles_Matrix_11132017.xlsx \
|
|
--out-yaml ../api-member-security-roles.yaml \
|
|
--out-md ../api-member-security-roles.md
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
import yaml
|
|
from openpyxl import load_workbook
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# A level description line looks like "ALL: text..." or "NONE: text..."
|
|
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
|
|
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
|
|
|
|
# Recognized ConnectWise permission levels, most-to-least privileged.
|
|
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
|
|
|
|
VERBS = ["add", "edit", "delete", "inquire"]
|
|
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
|
|
|
|
|
|
@dataclass
|
|
class CellPermission:
|
|
"""Parsed contents of a single (action, verb) cell."""
|
|
|
|
levels: Dict[str, str] = field(default_factory=dict) # level -> description
|
|
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
|
|
raw: str = "" # original cell text, preserved for audit
|
|
|
|
|
|
@dataclass
|
|
class ActionRow:
|
|
module: str
|
|
action: str
|
|
permissions: Dict[str, CellPermission] # verb -> CellPermission
|
|
|
|
|
|
def parse_cell(raw: Optional[str]) -> CellPermission:
|
|
"""Parse a single cell's multi-line content into levels + note."""
|
|
if raw is None:
|
|
return CellPermission(raw="")
|
|
text = str(raw).strip()
|
|
cp = CellPermission(raw=text)
|
|
if not text:
|
|
return cp
|
|
|
|
# Split into candidate entries. Each entry is typically one line that
|
|
# starts with a level prefix, but description text can itself contain
|
|
# newlines. We therefore split on newlines and accumulate continuation
|
|
# lines into the preceding entry.
|
|
current_level: Optional[str] = None
|
|
current_buf: List[str] = []
|
|
note_buf: List[str] = []
|
|
|
|
def flush_level() -> None:
|
|
nonlocal current_level, current_buf
|
|
if current_level is not None:
|
|
cp.levels[current_level] = " ".join(current_buf).strip()
|
|
current_level = None
|
|
current_buf = []
|
|
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
m = LEVEL_LINE.match(line)
|
|
if m:
|
|
flush_level()
|
|
current_level = m.group(1).upper()
|
|
current_buf = [m.group(2).strip()]
|
|
elif current_level is not None:
|
|
current_buf.append(line)
|
|
else:
|
|
# No level prefix yet — belongs to the note.
|
|
note_buf.append(line)
|
|
flush_level()
|
|
|
|
if note_buf:
|
|
cp.note = " ".join(note_buf).strip()
|
|
|
|
return cp
|
|
|
|
|
|
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
|
|
wb = load_workbook(xlsx_path, data_only=True)
|
|
ws = wb.active # Single sheet in this workbook.
|
|
|
|
# Header row is row 2 per the source file; data begins row 3.
|
|
actions: List[ActionRow] = []
|
|
for r in range(3, ws.max_row + 1):
|
|
module = ws.cell(row=r, column=1).value
|
|
action = ws.cell(row=r, column=2).value
|
|
if not (module or action):
|
|
continue # skip fully empty rows
|
|
if not module or not action:
|
|
# Partial row — keep but flag. This shouldn't happen in the
|
|
# current source; if it does, the generator should fail loudly
|
|
# rather than silently produce wrong output.
|
|
raise ValueError(
|
|
f"Row {r} has a missing Module or Action: "
|
|
f"module={module!r}, action={action!r}"
|
|
)
|
|
|
|
perms: Dict[str, CellPermission] = {}
|
|
for verb, col in VERB_COLS.items():
|
|
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
|
|
|
|
actions.append(
|
|
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
|
|
)
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output: YAML
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
|
|
"""Build a plain-dict representation that YAML dumps cleanly."""
|
|
# Group by module, preserving action order within each module.
|
|
modules: Dict[str, List[ActionRow]] = {}
|
|
for a in actions:
|
|
modules.setdefault(a.module, []).append(a)
|
|
|
|
doc = {
|
|
"metadata": {
|
|
"source_file": source_file,
|
|
"generated_on": date.today().isoformat(),
|
|
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
|
|
"description": (
|
|
"ConnectWise security-role matrix. Each (module, action) entry "
|
|
"describes what each access level (ALL, MY, MINE, NONE) means "
|
|
"for the Add, Edit, Delete, and Inquire verbs. This is a "
|
|
"reference catalog, not a per-role assignment — role "
|
|
"assignments live in ConnectWise and are mirrored in the "
|
|
"ResolutionFlow integration config."
|
|
),
|
|
"level_order_most_to_least_privileged": LEVEL_ORDER,
|
|
},
|
|
"modules": {},
|
|
}
|
|
|
|
for module_name, rows in modules.items():
|
|
module_block = {"actions": {}}
|
|
for a in rows:
|
|
action_block: Dict[str, object] = {}
|
|
for verb in VERBS:
|
|
cell = a.permissions[verb]
|
|
entry: Dict[str, object] = {}
|
|
if cell.levels:
|
|
# Emit levels in canonical order, only those present.
|
|
entry["levels"] = {
|
|
lvl: cell.levels[lvl]
|
|
for lvl in LEVEL_ORDER
|
|
if lvl in cell.levels
|
|
}
|
|
if cell.note:
|
|
entry["note"] = cell.note
|
|
if not entry:
|
|
# Truly empty cell — represent explicitly so downstream
|
|
# consumers can distinguish "empty" from "missing".
|
|
entry["note"] = "(no description provided)"
|
|
action_block[verb] = entry
|
|
module_block["actions"][a.action] = action_block
|
|
doc["modules"][module_name] = module_block
|
|
|
|
return doc
|
|
|
|
|
|
class _LiteralStr(str):
|
|
"""Marker type so PyYAML renders long strings as block literals."""
|
|
|
|
|
|
def _literal_presenter(dumper, data):
|
|
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
|
|
|
|
yaml.add_representer(_LiteralStr, _literal_presenter)
|
|
|
|
|
|
def _use_block_style_for_long_strings(obj):
|
|
"""Recursively wrap long strings so the YAML is readable, not one-line."""
|
|
if isinstance(obj, dict):
|
|
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_use_block_style_for_long_strings(v) for v in obj]
|
|
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
|
|
return _LiteralStr(obj)
|
|
return obj
|
|
|
|
|
|
def dump_yaml(doc: dict, out_path: Path) -> None:
|
|
prepared = _use_block_style_for_long_strings(doc)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_path.open("w", encoding="utf-8") as f:
|
|
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
|
|
f.write("# Generated from the source XLSX; do not edit by hand.\n")
|
|
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
|
|
yaml.dump(
|
|
prepared,
|
|
f,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
width=100,
|
|
default_flow_style=False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output: Markdown
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _md_escape(text: str) -> str:
|
|
"""Escape pipes and collapse whitespace for Markdown table cells."""
|
|
return text.replace("|", "\\|").replace("\n", " ").strip()
|
|
|
|
|
|
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
|
|
modules: Dict[str, List[ActionRow]] = {}
|
|
for a in actions:
|
|
modules.setdefault(a.module, []).append(a)
|
|
|
|
lines: List[str] = []
|
|
lines.append("# ConnectWise API Member — Security Roles Reference")
|
|
lines.append("")
|
|
lines.append(
|
|
f"_Generated {date.today().isoformat()} from "
|
|
f"`{source_file}`. Do not edit by hand — update the XLSX and "
|
|
f"re-run `generate_role_docs.py`._"
|
|
)
|
|
lines.append("")
|
|
lines.append("## How to read this document")
|
|
lines.append("")
|
|
lines.append(
|
|
"Each ConnectWise module lists the actions it governs. For every "
|
|
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
|
|
"**Inquire** — can be granted at one of these levels, most to "
|
|
"least privileged:"
|
|
)
|
|
lines.append("")
|
|
lines.append("| Level | Meaning |")
|
|
lines.append("|-------|---------|")
|
|
lines.append("| `ALL` | Access to all records in the system. |")
|
|
lines.append("| `MY` | Access to records owned by the user's team. |")
|
|
lines.append("| `MINE` | Access only to records owned by the user. |")
|
|
lines.append("| `NONE` | No access. |")
|
|
lines.append("")
|
|
lines.append(
|
|
"Not every level applies to every action — the source matrix "
|
|
"only documents the levels that are meaningful for each cell. "
|
|
"Cells marked _Not applicable_ reference another verb (usually "
|
|
"Inquire) where the meaningful level is defined."
|
|
)
|
|
lines.append("")
|
|
lines.append(
|
|
"The machine-readable form of this document is "
|
|
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
|
|
"Use the YAML when writing integration code; use this Markdown "
|
|
"when reviewing, discussing, or onboarding."
|
|
)
|
|
lines.append("")
|
|
lines.append("## Table of contents")
|
|
lines.append("")
|
|
for module_name in modules:
|
|
anchor = module_name.lower().replace(" ", "-").replace("/", "")
|
|
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
|
|
lines.append("")
|
|
|
|
for module_name, rows in modules.items():
|
|
lines.append(f"## {module_name}")
|
|
lines.append("")
|
|
for a in rows:
|
|
lines.append(f"### {a.action}")
|
|
lines.append("")
|
|
lines.append("| Verb | Level | Description |")
|
|
lines.append("|------|-------|-------------|")
|
|
wrote_any = False
|
|
for verb in VERBS:
|
|
cell = a.permissions[verb]
|
|
if cell.levels:
|
|
for lvl in LEVEL_ORDER:
|
|
if lvl in cell.levels:
|
|
lines.append(
|
|
f"| {verb.capitalize()} | `{lvl}` | "
|
|
f"{_md_escape(cell.levels[lvl])} |"
|
|
)
|
|
wrote_any = True
|
|
elif cell.note:
|
|
lines.append(
|
|
f"| {verb.capitalize()} | — | "
|
|
f"_{_md_escape(cell.note)}_ |"
|
|
)
|
|
wrote_any = True
|
|
if not wrote_any:
|
|
lines.append("| — | — | _(no description provided)_ |")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def write_markdown(md_text: str, out_path: Path) -> None:
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(md_text, encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", type=Path, required=True,
|
|
help="Path to the source .xlsx")
|
|
parser.add_argument("--out-yaml", type=Path, required=True,
|
|
help="Path to write the YAML output")
|
|
parser.add_argument("--out-md", type=Path, required=True,
|
|
help="Path to write the Markdown output")
|
|
args = parser.parse_args()
|
|
|
|
actions = read_matrix(args.source)
|
|
doc = build_yaml_document(actions, source_file=args.source.name)
|
|
dump_yaml(doc, args.out_yaml)
|
|
|
|
md = build_markdown(actions, source_file=args.source.name)
|
|
write_markdown(md, args.out_md)
|
|
|
|
# Quick data-quality summary to stdout — helpful when re-running after edits.
|
|
from collections import Counter
|
|
modules_seen = Counter(a.module for a in actions)
|
|
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
|
|
for m, n in modules_seen.most_common():
|
|
print(f" {m}: {n}")
|
|
print(f"\nWrote {args.out_yaml}")
|
|
print(f"Wrote {args.out_md}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|