Files
claudetools/clients/cascades-tucson/docs/migration/scripts/parse-synology-inventory.py
Howard Enos af4ad0aea3 cascades: CS-SERVER preflight verified + Synology discovery complete
CS-SERVER post-reboot verification: time sync, TLS 1.2 enforcement, and
Windows Server Backup feature all persisted cleanly. dcdiag clean. Ready
for Entra Connect install.

Synology cascadesDS permission inventory captured via DSM API (SSH
disabled by default on Synology). 35 users, 4 groups, 10 shares.
Analysis identifies 7 shared-account role logins (HIPAA violation),
8 departed-employee accounts to clean up, and 4 shares needing
Meredith-side confirmation before migration (pacs most sensitive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:59:38 -07:00

244 lines
9.8 KiB
Python

"""Parse the raw Synology DSM API discovery dump into a clean inventory doc.
Input: docs/migration/synology-permission-inventory-raw.md
Output: docs/migration/synology-permission-inventory.md (clean digest + mapping table)
"""
import json
import re
from pathlib import Path
RAW = Path('clients/cascades-tucson/docs/migration/synology-permission-inventory-raw.md')
OUT = Path('clients/cascades-tucson/docs/migration/synology-permission-inventory.md')
def extract_json_after(label, text):
"""Find the first JSON object after '--- <label> ---' marker using bracket-matching."""
idx = text.find(f'--- {label} ---')
if idx < 0:
return None
start = text.find('{', idx)
if start < 0:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if esc:
esc = False
continue
if c == "\\":
esc = True
continue
if c == '"':
in_str = not in_str
continue
if in_str:
continue
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
def find_all_json_after(label_pattern, text):
"""Find every JSON block whose label matches the regex pattern."""
results = []
for m in re.finditer(r'--- (' + label_pattern + r') ---\n', text):
label = m.group(1)
body = extract_json_after(label, text[m.start():])
if body is None:
continue
try:
results.append((label, json.loads(body)))
except json.JSONDecodeError:
pass
return results
def main():
content = RAW.read_text(encoding='utf-8')
content = re.sub(r'\n{2,}', '\n', content)
# USERS
users_obj = json.loads(extract_json_after('SYNO.Core.User list', content))
users = users_obj.get('data', {}).get('users', [])
# GROUPS
groups_obj = json.loads(extract_json_after('SYNO.Core.Group list', content))
groups = groups_obj.get('data', {}).get('groups', [])
# SHARES
shares_obj = json.loads(extract_json_after('SYNO.Core.Share list', content))
shares = shares_obj.get('data', {}).get('shares', [])
# PER-SHARE PERMISSIONS
perms = find_all_json_after(r"share '[^']+' - local (?:user|group) permissions", content)
# Build a lookup: share_name -> {'users': {name: flags}, 'groups': {name: flags}}
share_perms = {}
for label, obj in perms:
m = re.match(r"share '([^']+)' - local (user|group) permissions", label)
if not m:
continue
sname, scope = m.group(1), m.group(2)
share_perms.setdefault(sname, {'user': {}, 'group': {}})
for item in obj.get('data', {}).get('items', []):
nm = item.get('name', '?')
share_perms[sname][scope][nm] = {
'admin': item.get('is_admin', False),
'deny': item.get('is_deny', False),
'ro': item.get('is_readonly', False),
'rw': item.get('is_writable', False),
'custom': item.get('is_custom', False),
}
# Effective write-access table: for each share, who can actually write (writable OR admin) AND not denied
def effective(flags):
if flags['deny']:
return 'DENY'
if flags['rw']:
return 'RW'
if flags['admin']:
return 'Admin(full)'
if flags['ro']:
return 'RO'
return '-'
# Write the digest
out_lines = []
out_lines.append('# Synology cascadesDS — Permission Inventory (2026-04-22 via DSM API)')
out_lines.append('')
out_lines.append('**Source:** `docs/migration/synology-permission-inventory-raw.md`')
out_lines.append('**Method:** DSM HTTP API v7 via CS-SERVER GuruRMM agent (SSH not enabled on Synology as of 2026-04-22)')
out_lines.append('')
out_lines.append('## Summary counts')
out_lines.append('')
out_lines.append(f'- Synology local users: **{len(users)}**')
out_lines.append(f'- Synology local groups: **{len(groups)}**')
out_lines.append(f'- Shares: **{len(shares)}**')
out_lines.append('')
# Users
out_lines.append('## Users')
out_lines.append('')
out_lines.append('| Name | UID | Expired | Email | Description | Groups |')
out_lines.append('|---|---:|---|---|---|---|')
for u in sorted(users, key=lambda x: x.get('name', '').lower()):
name = u.get('name', '?')
uid = u.get('uid', '-')
expired = 'yes' if u.get('expired') in ('now', 'expired', True) else 'no'
email = u.get('email', '') or ''
desc = (u.get('description', '') or '').replace('|', r'\|')
groups_list = u.get('additional', {}).get('groups', []) if isinstance(u.get('additional'), dict) else []
groups_str = ', '.join(groups_list) if groups_list else '-'
out_lines.append(f'| `{name}` | {uid} | {expired} | {email} | {desc} | {groups_str} |')
out_lines.append('')
# Groups
out_lines.append('## Groups')
out_lines.append('')
out_lines.append('| Name | GID | Type | Description | Members |')
out_lines.append('|---|---:|---|---|---|')
for g in groups:
name = g.get('name', '?')
gid = g.get('gid', '-')
gtype = g.get('type', '-') or '-'
desc = g.get('description', '') or ''
members = g.get('additional', {}).get('members', []) if isinstance(g.get('additional'), dict) else []
member_names = ', '.join([m.get('name', '?') if isinstance(m, dict) else str(m) for m in members]) or '(unable to enumerate via API — error 3201)'
out_lines.append(f'| `{name}` | {gid} | {gtype} | {desc} | {member_names} |')
out_lines.append('')
out_lines.append('> Note: DSM API `SYNO.Core.Group.Member` returned error 3201 for all groups with this API path; membership was only partially available via the `members` additional field on the group list call. Full membership requires DSM web UI or CLI `synogroup --get <name>` via SSH.')
out_lines.append('')
# Shares + permissions
out_lines.append('## Shares')
out_lines.append('')
out_lines.append('| Share | Volume Path | Hidden | Description |')
out_lines.append('|---|---|---|---|')
for s in shares:
name = s.get('name', '?')
vol = s.get('vol_path', '') or s.get('additional', {}).get('vol_path', '') or ''
hidden = s.get('additional', {}).get('hidden', False) if isinstance(s.get('additional'), dict) else False
desc = (s.get('additional', {}).get('desc', '') or '') if isinstance(s.get('additional'), dict) else ''
out_lines.append(f'| `{name}` | {vol} | {hidden} | {desc} |')
out_lines.append('')
# Effective permissions per share (write-capable or explicit-deny users/groups)
out_lines.append('## Effective share permissions')
out_lines.append('')
out_lines.append('"Admin(full)" means the account inherits full control via administrators-group membership. "RW" means explicitly writable. "DENY" means explicitly denied (wins over everything else in Synology ACL order). Blank / absent means no explicit permission (effectively no access beyond what the users group grants).')
out_lines.append('')
# Identity sets to call out
known_current_employees = {
'Ashley Jensen', 'CasAdmin201', 'ChristinaDupras', 'Crystal Rodriguez',
'Crystal Suszek', 'Dining Manager', 'JD Martin', 'John Trozzi',
'Karen Rossini', 'Lois Lane', 'Lupe Sanchez', 'Megan Hiatt',
'meredith kuhn', 'Shelby Trozzi', 'Stephanie Devin', 'Susan Hicks',
'Veronica'
}
known_departed = {
'Amber M Lee', 'Ann Dery', 'Anna Pitzlin', 'Britney Thompson',
'Haris Durut', 'Monica RamirezRossette', 'Nela Durut-Azizi',
'Tamra Johnson'
}
role_accounts = {
'Accounting', 'Dining Manager', 'Front Desk', 'Memcare Receptionist',
'mcnurse', 'memcarenurse', 'Nurse Tower'
}
service_or_admin = {'admin', 'guest', 'guru', 'VPNClient'}
for sname in sorted(share_perms.keys()):
out_lines.append(f'### Share: `{sname}`')
out_lines.append('')
# Groups first
g_perms = share_perms[sname]['group']
out_lines.append('**Groups:**')
out_lines.append('')
out_lines.append('| Group | Effective |')
out_lines.append('|---|---|')
for gname in sorted(g_perms.keys()):
out_lines.append(f'| `{gname}` | {effective(g_perms[gname])} |')
out_lines.append('')
# Users with non-default effective permission
u_perms = share_perms[sname]['user']
nondefault = {n: f for n, f in u_perms.items()
if f['rw'] or f['ro'] or f['deny'] or f['admin']}
if nondefault:
out_lines.append('**Users with explicit permission:**')
out_lines.append('')
out_lines.append('| User | Effective | Status |')
out_lines.append('|---|---|---|')
for n in sorted(nondefault.keys(), key=lambda x: x.lower()):
eff = effective(nondefault[n])
if n in known_departed:
status = 'DEPARTED'
elif n in role_accounts:
status = 'ROLE (HIPAA concern)'
elif n in service_or_admin:
status = 'service/admin'
elif n in known_current_employees:
status = 'current'
else:
status = '?'
out_lines.append(f'| `{n}` | {eff} | {status} |')
out_lines.append('')
else:
out_lines.append('*(No user-level overrides — share access is group-based only.)*')
out_lines.append('')
OUT.write_text('\n'.join(out_lines), encoding='utf-8')
print(f'Wrote {OUT}')
print(f'Bytes: {OUT.stat().st_size}')
if __name__ == '__main__':
main()