docs: update permissions audit with re-audit findings

Re-audited after RBAC commit (34daa26). Key findings:
- permissions.py is dead code (no endpoint imports it)
- require_engineer_or_admin blocks team admins with viewer role
- 49 endpoints bypass get_current_active_user
- 3 critical issues still open (role field, XSS, secret key)
- Updated implementation plan with new Phase B items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-05 17:53:25 -05:00
parent 02bd97948e
commit fd8fab97bd
2 changed files with 302 additions and 269 deletions

View File

@@ -1,194 +1,151 @@
# Permissions & RBAC Audit — Design Document
> **Date:** 2026-02-05
> **Status:** Draft
> **Status:** Updated after re-audit
> **Scope:** Full-stack permissions audit across trees, steps, sessions, teams, and admin features
---
## Background
ResolutionFlow has a role-based access control system with three roles (`admin`, `engineer`, `viewer`), a `is_team_admin` boolean, and team-scoped resources. A comprehensive audit was performed across the entire codebase — frontend UX, backend architecture, and adversarial analysis — to assess the current state of permissions enforcement.
ResolutionFlow has a role-based access control system with a role hierarchy (`super_admin` > `team_admin` > `engineer` > `viewer`), backed by `is_super_admin` and `is_team_admin` boolean flags on the User model, plus team-scoped resources.
**Audit methodology:** Three independent reviews were conducted:
1. **Frontend UX audit**every page, component, API client, store, and router
2. **Backend architecture audit** — every endpoint, dependency, model, schema, and test
3. **Devil's advocate critique** — security gaps, edge cases, challenged assumptions
Two rounds of audit were performed:
1. **Initial audit**three independent reviews (UX, architecture, adversarial)
2. **Re-audit** — after RBAC implementation commit (`34daa26`) added `is_super_admin`, `permissions.py`, `usePermissions.ts`, and updated endpoint permission checks
**Audit methodology:** Each round used three specialized reviewers:
- Frontend UX audit — every page, component, API client, store, and router
- Backend architecture audit — every endpoint, dependency, model, schema, and test
- Gap analysis / devil's advocate — security gaps, edge cases, challenged assumptions
---
## Current State Summary
## Current Architecture (Post-RBAC Commit)
### What Was Added in Commit `34daa26`
- `is_super_admin` boolean on User model (migration 010)
- `backend/app/core/permissions.py` — centralized permission functions (role hierarchy, content guards)
- `frontend/src/hooks/usePermissions.ts` — permission hook with role hierarchy
- `require_admin` dependency now checks `is_super_admin` instead of `role == "admin"`
- `require_engineer_or_admin` dependency blocks viewers from tree/step creation
- `usePermissions` hook used in TreeLibraryPage (edit/create gating) and TreeEditorPage (access guard)
- Role badge display in AppLayout header
- `TreeListItem` type includes `team_id` for frontend permission checks
### What Works
- **Session ownership** is properly enforced — users can only see/modify their own sessions
- **Folder ownership** is properly scoped to the creating user
- **Tree CRUD** has reasonable backend permission checks (engineer+ to create, author/admin/team-admin to edit, admin-only delete)
- **Tree CRUD** has backend permission checks (engineer+ to create, author/admin/team-admin to edit, super-admin-only delete)
- **Step Library** enforces visibility levels (private/team/public) and ownership for edit/delete
- **Category/Tag management** is properly gated to admin and team admin roles
- **Invite code management** is admin-only
- **Category/Tag management** is properly gated to super admin and team admin roles
- **Invite code management** is super-admin-only
- **Frontend gating (partial):** Create Tree button hidden from viewers, Edit button checks ownership/team/admin
- **`is_super_admin` flag** prevents the old admin role escalation (flag has `server_default=false`)
### What's Broken
### What's Still Broken
The permission system has critical vulnerabilities, significant gaps, and inconsistencies across resource types.
The permission system still has critical vulnerabilities, a dead-code centralized module, and significant gaps.
---
## Findings by Severity
## Findings — Combined Status Table
### CRITICAL
### Legend
- **FIXED** — Issue fully resolved
- **PARTIAL** — Infrastructure exists but incomplete or not fully wired
- **OPEN** — No change from original audit
#### 1. Self-Assignable Admin Role at Registration
| # | Severity | Finding | Status | Evidence |
|---|----------|---------|--------|----------|
| 1 | CRITICAL | Self-assignable role at registration | **OPEN** | `UserCreate` still has `role` field (`schemas/user.py:14`). `auth.py:78` passes `user_data.role` to User. No enum validation — arbitrary strings accepted. The `is_super_admin` escalation is blocked (server default false), but `role` field is unvalidated. |
| 2 | CRITICAL | XSS in HTML export | **OPEN** | `sessions.py:349-405` — raw f-string interpolation without `html.escape()`. All user content (tree_name, ticket_number, client_name, question, answer, notes, scratchpad) injected unescaped. |
| 3 | CRITICAL | Default secret key in source code | **OPEN** | `config.py:29` — default string with no production validation. |
| 4 | HIGH | No access control on `start_session` | **OPEN** | `sessions.py:70-109` — checks tree existence and active status only. No tree access check. Any authenticated user can read any tree's full structure via session snapshot. |
| 5 | HIGH | No token revocation | **OPEN** | Logout (`auth.py:188-193`) is still a no-op. JWTs valid until expiry. |
| 6 | HIGH | No account deactivation (`is_active`) | **OPEN** | No `is_active` field. `get_current_active_user` (`deps.py:66-72`) is a no-op. |
| 7 | HIGH | No rate limiting | **OPEN** | No rate limiting on any endpoint. |
| 8 | HIGH | Admin flags cannot be set via API | **PARTIAL** | `is_super_admin` field added with migration 010. But no API endpoint exists to set `is_super_admin` or `is_team_admin`. Must be set via direct DB access. |
| 9 | MEDIUM | Frontend role awareness | **PARTIAL** | `usePermissions` hook exists, used in 3 files (TreeLibraryPage, TreeEditorPage, AppLayout). But: `ProtectedRoute` still auth-only, no route-level role guards, no 403 handling in Axios, no admin pages. |
| 10 | MEDIUM | `None == None` team visibility bug | **PARTIAL** | Fixed in `trees.py:33` (`if current_user.team_id else False`). Still present in local helpers: `steps.py:36`, `categories.py:31`, `step_categories.py:28`. |
| 11 | MEDIUM | Admin list vs get tree inconsistency | **OPEN** | `build_tree_access_filter` has no admin bypass. `get_tree` checks `is_super_admin`. |
| 12 | MEDIUM | No audit logging | **OPEN** | No change. |
| 13 | MEDIUM | No delete UI for trees or steps | **OPEN** | API methods exist, no buttons wired. `canDeleteTree` exists in hook but unused. |
| 14 | MEDIUM | Node deletion without confirmation | **OPEN** | No change. |
| 15 | LOW | Viewer role barely enforced | **PARTIAL** | `require_engineer_or_admin` blocks tree/step creation. Viewers can still create folders, start sessions, etc. |
| 16 | LOW | No password complexity | **OPEN** | No change. |
| 17 | LOW | Soft delete cascade cleanup | **OPEN** | No change. |
| 18 | LOW | Debug endpoint in production | **OPEN** | No change. |
| 19 | LOW | Tag search wildcard escaping | **OPEN** | No change. |
| 20 | LOW | No team existence validation | **OPEN** | No change. |
**Location:** `backend/app/schemas/user.py:14`, `backend/app/api/endpoints/auth.py:74-78`
**Summary: 0 FIXED / 4 PARTIAL / 16 OPEN**
The `UserCreate` schema accepts a `role` field from client input. The registration endpoint passes it directly to the User model. Any user with a valid invite code can register with `role: "admin"` and gain full admin privileges.
---
## New Issues Found in Re-Audit
### N1. `permissions.py` is Dead Code (HIGH)
**Location:** `backend/app/core/permissions.py`
The centralized permissions module defines `can_edit_tree`, `can_delete_tree`, `can_edit_step`, `can_manage_category`, `can_manage_tree_tags`, `has_minimum_role`, `get_effective_role`, and `can_create_content`.
**None of these functions are imported or called by any endpoint.** Zero imports across the entire backend.
Each endpoint file (`trees.py`, `steps.py`, `categories.py`, `tags.py`, `step_categories.py`, `folders.py`) defines its own inline permission helpers that are similar but not identical. Key differences:
| Function in `permissions.py` | Local equivalent | Difference |
|------------------------------|-----------------|------------|
| `can_edit_tree` | `trees.py:403-407` inline | Local version doesn't call `can_create_content` |
| `can_manage_category` | `categories.py:25-33` inline | Local version lacks `team_id is not None` guard |
| `can_manage_step_category` | `step_categories.py:22-41` inline | Local version lacks `team_id is not None` guard |
| `has_minimum_role` / `get_effective_role` | Not used anywhere | Entirely dead code |
**Risk:** False confidence that permissions are centralized. Maintaining two diverging copies.
### N2. `require_engineer_or_admin` Blocks Team Admins with Viewer Role (BUG)
**Location:** `backend/app/api/deps.py:87-98`
```python
# Current — VULNERABLE
class UserCreate(UserBase):
role: str = Field(default="engineer", ...)
# Registration blindly trusts client input
new_user = User(role=user_data.role, ...)
async def require_engineer_or_admin(current_user):
if current_user.is_super_admin:
return current_user
if current_user.role not in ("engineer",):
raise HTTPException(403)
```
**Impact:** Complete authorization bypass. A single request escalates to admin.
Checks `is_super_admin` but NOT `is_team_admin`. A user with `role="viewer"` + `is_team_admin=True` would be blocked from creating trees — contradicting the role hierarchy where team_admin > engineer.
#### 2. XSS in HTML Export
### N3. 49 Endpoints Bypass `get_current_active_user` (HIGH)
**Location:** `backend/app/api/endpoints/sessions.py:349-405`
**Location:** All endpoint files
The HTML export function directly interpolates user-provided strings without escaping:
```python
html.append(f'<h1>{tree_name}</h1>')
html.append(f'<p><strong>Ticket:</strong> {session.ticket_number}</p>')
html.append(f'<p><strong>Client:</strong> {session.client_name}</p>')
```
Most endpoints depend directly on `get_current_user`, bypassing `get_current_active_user`. If `is_active` were implemented, deactivated users could still: list/view sessions, export, update scratchpads, manage folders, list/view categories, tags, steps, rate steps, etc.
If any field contains `<script>` tags, the exported HTML becomes an XSS payload. Since exports are likely pasted into ticketing systems or emailed, this is a stored XSS vector.
### N4. `role` Field Accepts Arbitrary Strings (MEDIUM)
**Impact:** Arbitrary JavaScript execution in any system that renders the exported HTML.
**Location:** `backend/app/schemas/user.py:14`, `backend/app/models/user.py:27`
#### 3. Default Secret Key in Source Code
The `role` field is `String(50)` with no DB constraint or schema validation. Users could register with `role="superadmin"`, `role="root"`, etc. While these don't grant permissions (only `is_super_admin` flag does), it creates inconsistent data. The `UserCreate` schema describes "engineer or viewer" but doesn't enforce it.
**Location:** `backend/app/core/config.py:29`
### N5. TreeEditorPage Checks `canCreateTrees` Not `canEditTree` (MEDIUM)
If `.env` is missing or `SECRET_KEY` isn't set, JWTs are signed with a publicly known default string. Anyone can forge admin tokens.
**Location:** `frontend/src/pages/TreeEditorPage.tsx:81-85`
**Impact:** Complete authentication bypass if deployed without proper `.env` configuration.
---
### HIGH
#### 4. No Access Control on `start_session`
**Location:** `backend/app/api/endpoints/sessions.py:69-109`
The endpoint checks tree existence and active status but does NOT check if the user has permission to access the tree. Any authenticated user can start a session on any active tree (including private/team-only) if they know the UUID, receiving the full `tree_structure` in the session's `tree_snapshot`.
**Impact:** Information disclosure — bypasses all tree visibility rules.
#### 5. No Token Revocation
**Location:** `backend/app/api/endpoints/auth.py:188-193`
Logout is a client-side no-op. JWTs remain valid until expiration (15 min access, 7 day refresh). Combined with no account deactivation, a terminated employee retains 7 days of access.
**Impact:** Cannot immediately revoke access to client troubleshooting data.
#### 6. No Account Deactivation (`is_active`)
**Location:** `backend/app/api/deps.py:66-72`
The `get_current_active_user` dependency is a no-op — no `is_active` field exists on the User model. The only way to block a user is to delete their database row, which cascades all their data.
**Impact:** No graceful way to disable accounts.
#### 7. No Rate Limiting on Auth Endpoints
Login, registration, and invite code validation have no rate limiting. The unauthenticated `/invites/validate/{code}` endpoint allows unlimited invite code enumeration.
**Impact:** Brute force attacks and invite code enumeration.
#### 8. `is_team_admin` Cannot Be Set
**Location:** `backend/app/models/user.py:28`
The field exists and permission checks reference it, but no API endpoint can set it. The entire "team admin" permission tier is dead code in production.
**Impact:** Category management, step category management, and team-scoped tag management are admin-only in practice.
---
### MEDIUM
#### 9. Frontend Ignores All Roles
**Location:** `frontend/src/components/layout/ProtectedRoute.tsx`, `frontend/src/router.tsx`
`ProtectedRoute` only checks `isAuthenticated`. No role-based route guards, no conditional UI rendering, no admin pages. All users see the same interface regardless of role.
Specific issues:
- Edit button appears on ALL tree cards for ALL users (`TreeLibraryPage.tsx:309-318`)
- "Create Tree" button visible to viewers who will get 403 (`TreeLibraryPage.tsx:146-155`)
- No 403 error handling in the Axios interceptor
- No admin dashboard despite backend admin APIs existing
#### 10. `None == None` Team Visibility Bug
**Location:** `backend/app/api/endpoints/steps.py:29-37`
```python
if step.visibility == 'team':
return step.team_id == user.team_id or user.role == 'admin'
```
When both `step.team_id` and `user.team_id` are `None`, this evaluates to `True`. Users with no team can see "team" steps that also have no team.
#### 11. Admin List vs. Get Tree Inconsistency
`build_tree_access_filter` (used in `list_trees`) does NOT include an admin bypass, but `get_tree` does. Admins can access individual trees by ID but cannot discover them via the list endpoint.
#### 12. No Audit Logging
No record of administrative actions: tree deletion, invite code creation/revocation, category changes, permission modifications, or failed login attempts.
#### 13. No Delete UI for Trees or Steps
`treesApi.delete()` and `stepsApi.delete()` exist in the frontend API client but are never wired to any button or UI component. Trees and steps cannot be deleted from the frontend.
#### 14. Tree Node Deletion Without Confirmation
**Location:** `frontend/src/components/tree-editor/NodeList.tsx:288-298`
Clicking the trash icon in the tree editor immediately deletes the node with no confirmation dialog.
---
### LOW
| # | Issue | Location |
|---|-------|----------|
| 15 | `viewer` role accepted but barely enforced | `schemas/user.py`, all endpoints |
| 16 | No password complexity beyond 10-char minimum | `schemas/user.py:13` |
| 17 | Soft delete doesn't cascade-clean tags, folder memberships | `trees.py` delete endpoint |
| 18 | Debug CORS endpoint exposed in production | `main.py:86-94` |
| 19 | Tag search `ilike` doesn't escape `%` and `_` wildcards | `tags.py:97` |
| 20 | No team existence validation for admin step creation | `steps.py` create endpoint |
The editor page uses `canCreateTrees` (a blanket role check) instead of `canEditTree(tree)` (ownership/team check). A non-author engineer can navigate to `/trees/{someone-else's-id}/edit` and the frontend won't redirect them — the backend will block the save, but the UX is poor (loads editor, then fails on save).
---
## MSP-Specific Concerns
1. **Weak multi-tenancy:** Admin role transcends all teams. Team A's admin could access Team B's client troubleshooting data (ticket numbers, client names, IP addresses in scratchpads).
2. **No data retention/purge:** Session scratchpads and decision notes contain sensitive client information with no cleanup mechanism.
3. **No team-level session visibility:** Supervisors can't review their team's sessions — only the session owner can view them.
4. **Step Library IP leakage:** Public steps expose team-specific troubleshooting procedures (commands, scripts, procedures) to all teams with no approval workflow.
1. **Weak multi-tenancy:** Super admin transcends all teams. Team A's super admin could access Team B's client troubleshooting data.
2. **No data retention/purge:** Session scratchpads and notes contain sensitive client information with no cleanup mechanism.
3. **No team-level session visibility:** Supervisors can't review their team's sessions.
4. **Step Library IP leakage:** Public steps expose team-specific troubleshooting procedures to all teams.
---
@@ -196,11 +153,11 @@ Clicking the trash icon in the tree editor immediately deletes the node with no
| Assumption | Reality |
|-----------|---------|
| "Invite codes protect registration" | They don't prevent role escalation — the role field is independent |
| "Team scoping = tenant isolation" | Admin role transcends all teams with no boundary |
| "Soft delete is sufficient" | Leaves data artifacts (sessions with tree_snapshot, folder memberships, tag assignments) |
| "Invite codes protect registration" | They don't prevent arbitrary role strings — the role field is unvalidated |
| "Team scoping = tenant isolation" | Super admin transcends all teams with no boundary |
| "`permissions.py` centralizes permissions" | It's dead code — no endpoint imports it |
| "JWT statelessness is acceptable" | For an MSP tool with client data, inability to immediately revoke access is a liability |
| "Frontend doesn't need role awareness" | Without it, every user sees affordances they can't use — confusing UX and attacker map |
| "`usePermissions` hook covers the frontend" | Only used in 3 of ~10 components that need it |
---
@@ -210,18 +167,22 @@ The existing test suite does NOT cover:
- Cross-user resource access (user A accessing user B's sessions/folders/steps)
- Non-admin attempting tree deletion
- Viewer role restrictions
- Admin role escalation at registration
- Arbitrary role string at registration
- Team-based access controls (no team fixtures exist)
- Step library permission checks (no step tests at all)
- Category/tag/folder cross-user access
- `start_session` tree access bypass
- Concurrent session access across users
- Deactivated user access (blocked by no `is_active` field)
---
## References
- Frontend source: `frontend/src/`
- Backend endpoints: `backend/app/api/endpoints/`
- Centralized permissions (dead code): `backend/app/core/permissions.py`
- Frontend permissions hook: `frontend/src/hooks/usePermissions.ts`
- Auth dependencies: `backend/app/api/deps.py`
- RBAC questions doc: `docs/RBAC-IMPLEMENTATION-QUESTIONS.md`
- Backend endpoints: `backend/app/api/endpoints/`
- Models: `backend/app/models/`
- Tests: `backend/tests/`