chore: bump version and changelog (v0.1.0.0)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 18m54s
CI / frontend (pull_request) Failing after 47s
CI / e2e (pull_request) Has been skipped

Add CW security roles reference docs and PSA ticket management plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 14:44:03 +00:00
parent 294b309faa
commit bea34229d6
11 changed files with 7008 additions and 294 deletions

View File

@@ -0,0 +1,63 @@
# ConnectWise integration docs
Reference material for ResolutionFlow's ConnectWise Manage integration.
This folder pairs a **human-editable source** (the XLSX) with two
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
read the Markdown; edits happen in the XLSX.
## Files
| File | Role | Edit? |
|------|------|-------|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
## Regeneration workflow
After editing the XLSX:
```bash
cd docs/integrations/connectwise/source
pip install -r requirements.txt
python generate_role_docs.py \
--source Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
```
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
is what reviewers should scrutinize — it is the source of truth for code.
## Querying the YAML from integration code
The YAML groups permissions by module and action. Example — checking
what `Inquire: ALL` means for Service Desk → Service Tickets:
```python
import yaml
from pathlib import Path
doc = yaml.safe_load(
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
)
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
print(levels["ALL"])
```
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
layer should use when the required permission level for a given API
endpoint needs to be documented or validated against an assigned role.
## Conventions
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
- **`Not applicable` notes** in a verb's cell mean the meaningful level
is documented under another verb (almost always `inquire`) — the
generator preserves these as `note:` fields rather than inventing
placeholder levels.
- **The XLSX is the single source of input.** Never hand-edit the YAML
or Markdown; your changes will be overwritten on the next regeneration.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
"""
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()

View File

@@ -0,0 +1,5 @@
# Dependencies for generate_role_docs.py.
# These are only needed when regenerating the role docs from the XLSX —
# they are not runtime dependencies of ResolutionFlow itself.
openpyxl>=3.1,<4.0
PyYAML>=6.0,<7.0