Stage 1: Report Sharing (Read-Only Access)
Goal
Allow inviting users who can view specific reports but not edit anything. This is a lightweight solution before full project-based RBAC.
Requirements
- Granular access: Users granted access to specific reports (not all org reports)
- Two report types: Works for both AnalysisRun (single file) and MultiAnalysis
- Grant from multiple places:
- From report detail page (“Share” button)
- From user management page (assign reports to user)
- Source file access: Read-only users can view underlying files/transcripts for reports they have access to
Data Model Changes
New Table: report_access
CREATE TABLE report_access (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Polymorphic: one of these will be set
analysis_run_id UUID REFERENCES analysis_runs(id) ON DELETE CASCADE,
multi_analysis_id UUID REFERENCES multi_analyses(id) ON DELETE CASCADE,
granted_by_user_id UUID REFERENCES users(id),
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Constraint: exactly one of the IDs must be set
CONSTRAINT exactly_one_report CHECK (
(analysis_run_id IS NOT NULL)::int +
(multi_analysis_id IS NOT NULL)::int = 1
),
-- Unique constraint per user+report combination
CONSTRAINT unique_user_analysis UNIQUE (user_id, analysis_run_id),
CONSTRAINT unique_user_multi UNIQUE (user_id, multi_analysis_id)
);
CREATE INDEX idx_report_access_user ON report_access(user_id);
CREATE INDEX idx_report_access_analysis ON report_access(analysis_run_id) WHERE analysis_run_id IS NOT NULL;
CREATE INDEX idx_report_access_multi ON report_access(multi_analysis_id) WHERE multi_analysis_id IS NOT NULL;
User Role Addition
Add a new role to the existing user roles:
# Current roles: owner, admin, member, readonly
# Add distinction for report-only access
class UserRole(str, Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
READONLY = "readonly" # Existing: can view all org content
REPORT_VIEWER = "report_viewer" # New: can only view granted reports
Alternative: Keep role as-is and just check report_access table when role is readonly.
API Changes
New Endpoints
POST /reports/{type}/{id}/access
- type: "analysis" or "multi-analysis"
- Body: { "user_ids": ["uuid", ...] }
- Grants access to specified users
- Requires admin role
DELETE /reports/{type}/{id}/access/{user_id}
- Revokes access for a user
- Requires admin role
GET /reports/{type}/{id}/access
- Lists users with access to this report
- Requires admin role
GET /users/{id}/reports
- Lists reports a user has access to
- For admin: any user; for self: own reports
Modified Endpoints
GET /analyses/{id} and GET /analyze/multi/{id}
# Add access check
def can_view_report(user, report):
# Full access roles can always view
if user.role in ['owner', 'admin', 'member', 'readonly']:
return report.organization_id == user.organization_id
# Report viewers need explicit grant
if user.role == 'report_viewer':
return db.query(ReportAccess).filter(
ReportAccess.user_id == user.id,
ReportAccess.analysis_run_id == report.id # or multi_analysis_id
).first() is not None
return False
GET /files/{id} (for source file access)
def can_view_file(user, file):
# Full access roles
if user.role in ['owner', 'admin', 'member', 'readonly']:
return file.organization_id == user.organization_id
# Report viewers: can view if they have access to any analysis of this file
if user.role == 'report_viewer':
# Check if user has access to any AnalysisRun for this file
has_analysis_access = db.query(ReportAccess).join(
AnalysisRun, ReportAccess.analysis_run_id == AnalysisRun.id
).filter(
ReportAccess.user_id == user.id,
AnalysisRun.file_id == file.id
).first() is not None
# Or any MultiAnalysis containing this file
has_multi_access = db.query(ReportAccess).join(
MultiAnalysis, ReportAccess.multi_analysis_id == MultiAnalysis.id
).filter(
ReportAccess.user_id == user.id,
MultiAnalysis.file_ids.contains([file.id])
).first() is not None
return has_analysis_access or has_multi_access
return False
Frontend Changes
Report Detail Pages
AnalysisDetails.jsx and MultiAnalysisDetails.jsx
Add “Share” button (visible to admins):
<Button onClick={() => setShowShareDialog(true)}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<ShareReportDialog
reportId={analysis.id}
reportType="analysis" // or "multi-analysis"
currentAccess={accessList}
orgUsers={orgUsers}
onGrant={handleGrantAccess}
onRevoke={handleRevokeAccess}
/>
User Management Page
AdminUsers.jsx
Add “Reports” column or expandable section showing which reports a user can access:
<Button variant="ghost" onClick={() => setShowReportsDialog(user.id)}>
{user.report_count} reports
</Button>
<UserReportsDialog
userId={user.id}
currentReports={userReports}
allReports={orgReports}
onUpdate={handleUpdateReports}
/>
Dashboard/Navigation for Report Viewers
AppLayout.jsx - Conditional navigation:
{user.role === 'report_viewer' ? (
// Minimal nav for report viewers
<NavLink to="/my-reports">My Reports</NavLink>
) : (
// Full nav for other roles
<>
<NavLink to="/dashboard">Dashboard</NavLink>
<NavLink to="/files">Files</NavLink>
<NavLink to="/reports">Reports</NavLink>
{/* ... */}
</>
)}
New page: MyReports.jsx
- Lists only reports the user has access to
- Groups by type (single analyses, multi-analyses)
- Links to report detail pages
File Access for Report Viewers
When a report viewer views a report, they should be able to click through to see the source files:
- File detail page (read-only)
- Transcript viewer (read-only)
- No edit buttons, no process buttons, no delete
Invite Flow
Inviting a Report Viewer
- Admin clicks “Invite User” in admin panel
- Selects role = “Report Viewer”
- Optionally selects initial reports to grant access to
- User receives invite email
- User sets password, logs in
- User sees only their granted reports
Alternative: Invite from Report
- Admin views a report
- Clicks “Share”
- Enters email of new user (or selects existing)
- If new user: creates account with
report_viewerrole + sends invite - If existing user: grants access to this report
Security Considerations
-
Organization boundary: Report viewers still belong to an organization. They can’t see reports from other orgs even if somehow granted.
-
Cascading access: When a report viewer has access to a MultiAnalysis, they can view:
- The MultiAnalysis itself
- All Files included in that analysis (file_ids)
- Transcripts for those files
- NOT other analyses of those files (unless explicitly granted)
-
Audit trail:
granted_by_user_idandgranted_attrack who granted access when. -
No elevation: Report viewers cannot:
- Create files/analyses
- Edit anything
- Delete anything
- Invite other users
- See prompt templates
- See other users
Files to Create/Modify
Backend
api/src/shared/db_models.py- Add ReportAccess modelapi/migrations/versions/xxx_add_report_access.py- Migrationapi/src/routers/reports.py- New router for access managementapi/src/deps.py- Update auth checks for report_viewer roleapi/src/routers/files.py- Add report_viewer access checksapi/src/routers/analyses.py- Add report_viewer access checks
Frontend
client/src/components/ShareReportDialog.jsx- Newclient/src/components/UserReportsDialog.jsx- Newclient/src/pages/MyReports.jsx- Newclient/src/pages/AdminUsers.jsx- Add reports columnclient/src/pages/AnalysisDetails.jsx- Add share buttonclient/src/pages/MultiAnalysisDetails.jsx- Add share buttonclient/src/components/layout/AppLayout.jsx- Conditional navclient/src/contexts/AuthContext.jsx- Handle report_viewer role
Demo Criteria
- Admin creates a new user with “Report Viewer” role
- Admin opens a MultiAnalysis report, clicks “Share”, selects the new user
- Report viewer logs in, sees only that one report on their dashboard
- Report viewer opens the report, can read all content
- Report viewer clicks on a source file name, can view the file and transcript
- Report viewer sees no edit buttons, no delete buttons, no “New Analysis” buttons
- Admin revokes access, report viewer refreshes and no longer sees the report