Skip to content

Commit eddfcc4

Browse files
committed
Regenerate permissions documentation from backend source
- Add scripts/generate_permissions_docs.py to generate permissions tables from backend source files (data.json and org_initials.json) - Replace hardcoded permissions tables with auto-generated content - Add 18 previously missing resources (Attestation, Analytics, etc.) - Add missing permissions (workflow execution, delete comments, etc.) - Document model-level permissions with correct roles: - Model Owner, Model Developer, Model Validator, Basic User - Apply UI terminology mapping (Finding -> Artifact) - Add role descriptions for both org-level and model-level roles - Restructure page to explain two-level permission system
1 parent 8dd7f98 commit eddfcc4

3 files changed

Lines changed: 696 additions & 182 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate permissions documentation from backend source files.
4+
5+
This script reads the permission definitions from the backend repository
6+
and generates Quarto markdown tables for the documentation.
7+
8+
Source files:
9+
- backend/src/backend/templates/platform_resources/data.json
10+
- backend/src/backend/templates/platform_resources/org_initials.json
11+
12+
Usage:
13+
python scripts/generate_permissions_docs.py
14+
15+
Output:
16+
site/guide/configuration/_permissions-generated.qmd
17+
"""
18+
19+
import json
20+
import os
21+
import re
22+
from pathlib import Path
23+
from typing import Any
24+
25+
# Paths relative to documentation repo root
26+
SCRIPT_DIR = Path(__file__).parent
27+
REPO_ROOT = SCRIPT_DIR.parent
28+
BACKEND_ROOT = REPO_ROOT.parent / "backend"
29+
30+
DATA_JSON = BACKEND_ROOT / "src/backend/templates/platform_resources/data.json"
31+
ROLES_JSON = BACKEND_ROOT / "src/backend/templates/platform_resources/org_initials.json"
32+
OUTPUT_FILE = REPO_ROOT / "site/guide/configuration/_permissions-generated.qmd"
33+
34+
# UI terminology mapping (Finding -> Artifact)
35+
TERMINOLOGY_MAP = {
36+
"Finding": "Artifact",
37+
"finding": "artifact",
38+
}
39+
40+
41+
def format_action_name(action_id: str, action_name: str | None = None) -> str:
42+
"""Format action ID into human-readable name."""
43+
# Use provided name if available
44+
if action_name:
45+
name = action_name
46+
else:
47+
name = action_id
48+
49+
# Remove common prefixes
50+
for prefix in ["read_", "create_", "update_", "delete_", "add_", "manage_"]:
51+
if name.startswith(prefix):
52+
name = name[len(prefix):]
53+
break
54+
55+
# Replace underscores with spaces
56+
name = name.replace("_", " ")
57+
58+
# Capitalize first letter of each word
59+
name = " ".join(word.capitalize() for word in name.split())
60+
61+
# Apply terminology mapping
62+
for old, new in TERMINOLOGY_MAP.items():
63+
name = name.replace(old, new)
64+
65+
return name
66+
67+
68+
def format_resource_name(name: str) -> str:
69+
"""Format resource name for display."""
70+
# Apply terminology mapping
71+
for old, new in TERMINOLOGY_MAP.items():
72+
name = name.replace(old, new)
73+
return name
74+
75+
76+
def get_action_display_name(action: dict) -> str:
77+
"""Get display name for an action."""
78+
action_id = action.get("id", "")
79+
action_name = action.get("name", "")
80+
description = action.get("description", "")
81+
82+
# Build display name from action name or ID
83+
if action_name and action_name not in ["read", "create", "update", "delete", "add"]:
84+
display = action_name.replace("_", " ").title()
85+
else:
86+
# Use action_id but format it nicely
87+
display = action_id.replace("_", " ").title()
88+
89+
# Apply terminology mapping
90+
for old, new in TERMINOLOGY_MAP.items():
91+
display = display.replace(old, new)
92+
93+
return display
94+
95+
96+
def checkbox(checked: bool) -> str:
97+
"""Return HTML checkbox markup."""
98+
if checked:
99+
return '<input type="checkbox" disabled checked />'
100+
return '<input type="checkbox" disabled />'
101+
102+
103+
def load_json(path: Path) -> Any:
104+
"""Load JSON file."""
105+
with open(path, "r") as f:
106+
return json.load(f)
107+
108+
109+
def get_role_permissions(roles_data: dict) -> dict:
110+
"""Extract permissions for each role."""
111+
defaults = roles_data.get("defaults", {})
112+
all_roles_perms = set(defaults.get("all_roles", []))
113+
model_scope_perms = set(defaults.get("model_scope_roles", []))
114+
115+
role_perms = {}
116+
for role_name, role_data in roles_data.get("roles", {}).items():
117+
if role_name in ["Staff"]: # Skip internal roles
118+
continue
119+
perms = set(role_data.get("permissions", []))
120+
scope = role_data.get("scope", "Organization")
121+
is_basic = role_data.get("is_basic_role", False)
122+
123+
# Add default permissions
124+
if scope == "Organization":
125+
perms = perms.union(all_roles_perms)
126+
elif scope == "Model":
127+
perms = perms.union(model_scope_perms).union(all_roles_perms)
128+
129+
role_perms[role_name] = {
130+
"permissions": perms,
131+
"scope": scope,
132+
"is_basic": is_basic,
133+
}
134+
135+
return role_perms
136+
137+
138+
def has_permission(role_perms: dict, role_name: str, action_id: str) -> bool:
139+
"""Check if a role has a specific permission."""
140+
if role_name not in role_perms:
141+
return False
142+
143+
perms = role_perms[role_name]["permissions"]
144+
145+
# Direct match
146+
if action_id in perms:
147+
return True
148+
149+
# Wildcard match (e.g., read_cf_* matches read_cf_anything)
150+
for perm in perms:
151+
if perm.endswith("*"):
152+
prefix = perm[:-1]
153+
if action_id.startswith(prefix):
154+
return True
155+
156+
# Customer Admin has all permissions
157+
if role_name == "Customer Admin":
158+
return True
159+
160+
return False
161+
162+
163+
def generate_org_permissions_table(
164+
resources: list, role_perms: dict, org_roles: list
165+
) -> str:
166+
"""Generate markdown table for organization-level permissions."""
167+
lines = []
168+
169+
for resource in resources:
170+
resource_id = resource.get("id", "")
171+
resource_name = format_resource_name(resource.get("name", resource_id))
172+
parent_id = resource.get("parent_id")
173+
actions = resource.get("actions", [])
174+
175+
# Skip if no actions or if model-scoped
176+
if not actions or parent_id == "Model":
177+
continue
178+
179+
# Skip internal resources
180+
if resource_id.startswith("dt_") or resource_id in ["WorkflowStatus"]:
181+
continue
182+
183+
lines.append(f"\n#### {resource_name}\n")
184+
185+
# Build header
186+
header = "| Permission |"
187+
sep = "|---|"
188+
for role in org_roles:
189+
header += f" {role} |"
190+
sep += ":---:|"
191+
lines.append(header)
192+
lines.append(sep)
193+
194+
# Build rows
195+
for action in actions:
196+
action_id = action.get("id", "")
197+
is_visible = action.get("is_visible", True)
198+
if not is_visible:
199+
continue
200+
201+
display_name = get_action_display_name(action)
202+
row = f"| {display_name} |"
203+
204+
for role in org_roles:
205+
checked = has_permission(role_perms, role, action_id)
206+
row += f" {checkbox(checked)} |"
207+
208+
lines.append(row)
209+
210+
lines.append(f': **{resource_name}** permissions {{.hover tbl-colwidths="[40,20,20,20]"}}')
211+
212+
return "\n".join(lines)
213+
214+
215+
def generate_model_permissions_table(
216+
resources: list, role_perms: dict, model_roles: list
217+
) -> str:
218+
"""Generate markdown table for model-level permissions."""
219+
lines = []
220+
221+
for resource in resources:
222+
resource_id = resource.get("id", "")
223+
resource_name = format_resource_name(resource.get("name", resource_id))
224+
parent_id = resource.get("parent_id")
225+
actions = resource.get("actions", [])
226+
227+
# Only include model-scoped resources
228+
if parent_id != "Model" or not actions:
229+
continue
230+
231+
# Skip dt_ resources (document types) - internal
232+
if resource_id.startswith("dt_"):
233+
continue
234+
235+
lines.append(f"\n#### {resource_name}\n")
236+
237+
# Build header
238+
header = "| Permission |"
239+
sep = "|---|"
240+
for role in model_roles:
241+
header += f" {role} |"
242+
sep += ":---:|"
243+
lines.append(header)
244+
lines.append(sep)
245+
246+
# Build rows
247+
for action in actions:
248+
action_id = action.get("id", "")
249+
is_visible = action.get("is_visible", True)
250+
if not is_visible:
251+
continue
252+
253+
display_name = get_action_display_name(action)
254+
row = f"| {display_name} |"
255+
256+
for role in model_roles:
257+
checked = has_permission(role_perms, role, action_id)
258+
row += f" {checkbox(checked)} |"
259+
260+
lines.append(row)
261+
262+
lines.append(f': **{resource_name}** permissions {{.hover tbl-colwidths="[28,18,18,18,18]"}}')
263+
264+
return "\n".join(lines)
265+
266+
267+
def main():
268+
"""Generate permissions documentation."""
269+
print(f"Loading {DATA_JSON}")
270+
resources = load_json(DATA_JSON)
271+
272+
print(f"Loading {ROLES_JSON}")
273+
roles_data = load_json(ROLES_JSON)
274+
role_perms = get_role_permissions(roles_data)
275+
276+
# Define role groups
277+
org_roles = ["Customer Admin", "Developer", "Validator"]
278+
model_roles = ["Model Owner", "Model Developer", "Model Validator", "Basic User"]
279+
280+
# Generate output
281+
output = []
282+
283+
output.append("""<!---
284+
This file is auto-generated by scripts/generate_permissions_docs.py
285+
Do not edit directly. Re-run the script to update.
286+
287+
Source files:
288+
- backend/src/backend/templates/platform_resources/data.json
289+
- backend/src/backend/templates/platform_resources/org_initials.json
290+
--->
291+
292+
## Organization-level permissions
293+
294+
Organization-level permissions apply to all users in your organization based on their assigned role. These permissions control access to platform-wide settings and configurations.
295+
296+
::: {.callout-note}
297+
The [{{< fa hand >}} Customer Admin]{.bubble} role has full access to all organization-level permissions and cannot be modified.
298+
:::
299+
""")
300+
301+
output.append(generate_org_permissions_table(resources, role_perms, org_roles))
302+
303+
output.append("""
304+
305+
## Model-level permissions
306+
307+
Model-level permissions control what users can do within the context of a specific model. Users are assigned model-level roles when they are added as stakeholders to a model.
308+
309+
::: {.callout-tip}
310+
Model-level permissions are additive to organization-level permissions. A user with the Validator role at the organization level who is also assigned as Model Validator on a specific model will have both sets of permissions for that model.
311+
:::
312+
""")
313+
314+
output.append(generate_model_permissions_table(resources, role_perms, model_roles))
315+
316+
output.append("""
317+
318+
## Role descriptions
319+
320+
### Organization-level roles
321+
322+
| Role | Description |
323+
|------|-------------|
324+
| Customer Admin | Full administrative access to the organization. Can manage users, roles, and all settings. |
325+
| Validator | Can create and manage models, templates, guidelines, and perform validation activities. |
326+
| Developer | Can create models and view organization settings. Limited administrative access. |
327+
| Basic User | Minimal permissions. Can only access models they are explicitly added to as stakeholders. |
328+
329+
### Model-level roles
330+
331+
| Role | Description |
332+
|------|-------------|
333+
| Model Owner | Full control over a specific model including deletion and stakeholder management. |
334+
| Model Developer | Can edit model documentation and monitoring. |
335+
| Model Validator | Can perform validation activities, add findings, and edit validation reports. |
336+
: Role descriptions {.hover}
337+
""")
338+
339+
# Write output
340+
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
341+
with open(OUTPUT_FILE, "w") as f:
342+
f.write("\n".join(output))
343+
344+
print(f"Generated {OUTPUT_FILE}")
345+
346+
347+
if __name__ == "__main__":
348+
main()

0 commit comments

Comments
 (0)