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

  1. Granular access: Users granted access to specific reports (not all org reports)
  2. Two report types: Works for both AnalysisRun (single file) and MultiAnalysis
  3. Grant from multiple places:
    • From report detail page (“Share” button)
    • From user management page (assign reports to user)
  4. 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

  1. Admin clicks “Invite User” in admin panel
  2. Selects role = “Report Viewer”
  3. Optionally selects initial reports to grant access to
  4. User receives invite email
  5. User sets password, logs in
  6. User sees only their granted reports

Alternative: Invite from Report

  1. Admin views a report
  2. Clicks “Share”
  3. Enters email of new user (or selects existing)
  4. If new user: creates account with report_viewer role + sends invite
  5. If existing user: grants access to this report

Security Considerations

  1. Organization boundary: Report viewers still belong to an organization. They can’t see reports from other orgs even if somehow granted.

  2. 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)
  3. Audit trail: granted_by_user_id and granted_at track who granted access when.

  4. 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 model
  • api/migrations/versions/xxx_add_report_access.py - Migration
  • api/src/routers/reports.py - New router for access management
  • api/src/deps.py - Update auth checks for report_viewer role
  • api/src/routers/files.py - Add report_viewer access checks
  • api/src/routers/analyses.py - Add report_viewer access checks

Frontend

  • client/src/components/ShareReportDialog.jsx - New
  • client/src/components/UserReportsDialog.jsx - New
  • client/src/pages/MyReports.jsx - New
  • client/src/pages/AdminUsers.jsx - Add reports column
  • client/src/pages/AnalysisDetails.jsx - Add share button
  • client/src/pages/MultiAnalysisDetails.jsx - Add share button
  • client/src/components/layout/AppLayout.jsx - Conditional nav
  • client/src/contexts/AuthContext.jsx - Handle report_viewer role

Demo Criteria

  1. Admin creates a new user with “Report Viewer” role
  2. Admin opens a MultiAnalysis report, clicks “Share”, selects the new user
  3. Report viewer logs in, sees only that one report on their dashboard
  4. Report viewer opens the report, can read all content
  5. Report viewer clicks on a source file name, can view the file and transcript
  6. Report viewer sees no edit buttons, no delete buttons, no “New Analysis” buttons
  7. Admin revokes access, report viewer refreshes and no longer sees the report