Stage 5: Project-Level RBAC

Goal

Control who can access which projects and what they can do within them.

Key Design Decisions

  1. Simple roles: Viewer (read-only) and Editor (full CRUD)
  2. Org admin override: Org admins/owners automatically have full access to all projects
  3. Explicit membership: Regular users must be added to projects to see them
  4. Project creators: User who creates a project is automatically an Editor

Data Model Changes

New Table: project_memberships

CREATE TABLE project_memberships (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    role VARCHAR(20) NOT NULL DEFAULT 'viewer',  -- 'viewer' or 'editor'

    added_by_user_id UUID REFERENCES users(id),
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT valid_project_role CHECK (role IN ('viewer', 'editor')),
    CONSTRAINT unique_project_membership UNIQUE (project_id, user_id)
);

CREATE INDEX idx_project_memberships_project ON project_memberships(project_id);
CREATE INDEX idx_project_memberships_user ON project_memberships(user_id);

SQLAlchemy Model

class ProjectMembership(Base):
    __tablename__ = "project_memberships"

    id = Column(UUID, primary_key=True, default=uuid.uuid4)
    project_id = Column(UUID, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
    user_id = Column(UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)

    role = Column(String(20), nullable=False, default='viewer')

    added_by_user_id = Column(UUID, ForeignKey("users.id"))
    added_at = Column(DateTime, default=datetime.utcnow)

    # Relationships
    project = relationship("Project", back_populates="memberships")
    user = relationship("User", foreign_keys=[user_id], back_populates="project_memberships")
    added_by = relationship("User", foreign_keys=[added_by_user_id])

    __table_args__ = (
        UniqueConstraint('project_id', 'user_id', name='unique_project_membership'),
        CheckConstraint("role IN ('viewer', 'editor')", name='valid_project_role'),
    )

Update Project model:

class Project(Base):
    # ... existing ...
    memberships = relationship("ProjectMembership", back_populates="project", cascade="all, delete-orphan")

Update User model:

class User(Base):
    # ... existing ...
    project_memberships = relationship("ProjectMembership", foreign_keys="ProjectMembership.user_id", back_populates="user")

Access Control Logic

Core Access Function

# api/src/deps.py

class ProjectAccess(str, Enum):
    NONE = "none"
    VIEWER = "viewer"
    EDITOR = "editor"

def get_project_access(
    user: User,
    project: Project,
    db: Session
) -> ProjectAccess:
    """
    Determine user's access level to a project.

    Access hierarchy:
    1. Org owner/admin → Editor (full access to all projects)
    2. Explicit membership → role from project_memberships
    3. No membership → None
    """
    # Org admins have full access
    if user.role in ('owner', 'admin'):
        return ProjectAccess.EDITOR

    # Check explicit membership
    membership = db.query(ProjectMembership).filter(
        ProjectMembership.project_id == project.id,
        ProjectMembership.user_id == user.id
    ).first()

    if membership:
        return ProjectAccess(membership.role)

    return ProjectAccess.NONE


def require_project_access(
    min_access: ProjectAccess = ProjectAccess.VIEWER
):
    """
    Dependency that checks project access.
    Use as: Depends(require_project_access(ProjectAccess.EDITOR))
    """
    async def check_access(
        project_id: UUID = Path(...),
        auth: AuthContext = Depends(get_auth),
        db: Session = Depends(get_db)
    ):
        project = db.query(Project).filter(
            Project.id == project_id,
            Project.organization_id == auth.organization_id
        ).first()

        if not project:
            raise HTTPException(404, "Project not found")

        user = db.query(User).filter(User.id == auth.user_id).first()
        access = get_project_access(user, project, db)

        if access == ProjectAccess.NONE:
            raise HTTPException(403, "You don't have access to this project")

        if min_access == ProjectAccess.EDITOR and access == ProjectAccess.VIEWER:
            raise HTTPException(403, "You don't have edit access to this project")

        return project

    return check_access

Filter Projects by Access

def get_accessible_projects(user: User, db: Session) -> List[Project]:
    """Get all projects the user can access."""

    # Org admins see all projects
    if user.role in ('owner', 'admin'):
        return db.query(Project).filter(
            Project.organization_id == user.organization_id
        ).all()

    # Regular users see only projects they're members of
    return db.query(Project).join(
        ProjectMembership,
        ProjectMembership.project_id == Project.id
    ).filter(
        Project.organization_id == user.organization_id,
        ProjectMembership.user_id == user.id
    ).all()

API Changes

New Endpoints: Project Membership

GET    /projects/{id}/members
       - Lists all members of a project
       - Returns: array of { user, role, added_at }
       - Requires: project viewer access

POST   /projects/{id}/members
       - Add user to project
       - Body: { user_id, role }
       - Requires: org admin OR (project editor + admin creating non-editor)
       - Note: Only org admins can add editors

PUT    /projects/{id}/members/{user_id}
       - Update member's role
       - Body: { role }
       - Requires: org admin

DELETE /projects/{id}/members/{user_id}
       - Remove user from project
       - Requires: org admin OR self-removal

Modified Endpoints

GET /projects

@router.get("/projects")
async def list_projects(
    auth: AuthContext = Depends(get_auth),
    db: Session = Depends(get_db)
):
    user = db.query(User).filter(User.id == auth.user_id).first()
    projects = get_accessible_projects(user, db)

    return [ProjectResponse.from_orm(p) for p in projects]

All project-scoped endpoints now check access:

@router.get("/projects/{project_id}/files")
async def list_project_files(
    project: Project = Depends(require_project_access(ProjectAccess.VIEWER)),
    # ...
):
    # ...

@router.post("/projects/{project_id}/files")
async def create_file(
    project: Project = Depends(require_project_access(ProjectAccess.EDITOR)),
    # ...
):
    # ...

Frontend Changes

Project Members UI

Add to project settings or as a modal:

// components/ProjectMembersDialog.jsx

function ProjectMembersDialog({ project, open, onClose }) {
  const [members, setMembers] = useState([]);
  const [orgUsers, setOrgUsers] = useState([]);
  const { user } = useAuth();

  const isOrgAdmin = user.role === 'owner' || user.role === 'admin';

  const loadMembers = async () => {
    const data = await api.get(`/projects/${project.id}/members`);
    setMembers(data);
  };

  const handleAddMember = async (userId, role) => {
    await api.post(`/projects/${project.id}/members`, { user_id: userId, role });
    loadMembers();
    toast.success('Member added');
  };

  const handleUpdateRole = async (userId, role) => {
    await api.put(`/projects/${project.id}/members/${userId}`, { role });
    loadMembers();
    toast.success('Role updated');
  };

  const handleRemoveMember = async (userId) => {
    await api.delete(`/projects/${project.id}/members/${userId}`);
    loadMembers();
    toast.success('Member removed');
  };

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Project Members</DialogTitle>
          <DialogDescription>
            Manage who has access to {project.name}
          </DialogDescription>
        </DialogHeader>

        {/* Current members */}
        <div className="space-y-2">
          {members.map(member => (
            <div key={member.user.id} className="flex items-center justify-between p-2 border rounded">
              <div>
                <p className="font-medium">{member.user.email}</p>
                <p className="text-sm text-muted-foreground">{member.user.name}</p>
              </div>
              <div className="flex items-center gap-2">
                {isOrgAdmin ? (
                  <Select
                    value={member.role}
                    onValueChange={(role) => handleUpdateRole(member.user.id, role)}
                  >
                    <SelectTrigger className="w-24">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="viewer">Viewer</SelectItem>
                      <SelectItem value="editor">Editor</SelectItem>
                    </SelectContent>
                  </Select>
                ) : (
                  <Badge variant="outline">{member.role}</Badge>
                )}
                {isOrgAdmin && (
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleRemoveMember(member.user.id)}
                  >
                    <X className="h-4 w-4" />
                  </Button>
                )}
              </div>
            </div>
          ))}
        </div>

        {/* Add member */}
        {isOrgAdmin && (
          <AddMemberForm
            orgUsers={orgUsers}
            existingMembers={members}
            onAdd={handleAddMember}
          />
        )}
      </DialogContent>
    </Dialog>
  );
}
// In Sidebar.jsx

{projects.map(project => (
  <button
    key={project.id}
    onClick={() => setCurrentProject(project)}
    className={cn(
      "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm",
      currentProject?.id === project.id
        ? "bg-primary/10 text-primary"
        : "hover:bg-muted"
    )}
  >
    <div
      className="w-2 h-2 rounded-full"
      style={{ backgroundColor: project.color || '#6B7280' }}
    />
    <span className="truncate">{project.name}</span>
    {project.user_role === 'viewer' && (
      <Eye className="h-3 w-3 text-muted-foreground ml-auto" title="View only" />
    )}
  </button>
))}

Disable Edit Actions for Viewers

// In Files.jsx

const { currentProject } = useProjects();
const canEdit = currentProject?.user_role === 'editor';

return (
  <div>
    {canEdit && (
      <Button onClick={() => navigate('/upload')}>
        <Upload className="h-4 w-4 mr-2" />
        Upload File
      </Button>
    )}

    {/* File list */}
    {files.map(file => (
      <FileRow
        key={file.id}
        file={file}
        showActions={canEdit}
      />
    ))}
  </div>
);

Migration for Existing Users

When Stage 5 is deployed:

-- Add all existing users as editors to the default project
INSERT INTO project_memberships (project_id, user_id, role, added_at)
SELECT p.id, u.id, 'editor', NOW()
FROM projects p
CROSS JOIN users u
WHERE p.organization_id = u.organization_id
  AND p.is_default = TRUE
  AND u.role NOT IN ('owner', 'admin');  -- Org admins don't need explicit membership

Files to Create/Modify

Backend

  • api/src/shared/db_models.py - Add ProjectMembership model
  • api/migrations/versions/xxx_add_project_memberships.py - Migration
  • api/src/deps.py - Add access control functions
  • api/src/routers/projects.py - Add membership endpoints, update list
  • api/src/routers/files.py - Add access checks
  • api/src/routers/analyses.py - Add access checks

Frontend

  • client/src/components/ProjectMembersDialog.jsx - New
  • client/src/components/AddMemberForm.jsx - New
  • client/src/contexts/ProjectContext.jsx - Include user_role in project data
  • client/src/components/layout/Sidebar.jsx - Show access indicator
  • client/src/pages/Files.jsx - Conditional edit actions
  • client/src/pages/Upload.jsx - Check edit access
  • client/src/pages/MultiAnalysis.jsx - Check edit access

Demo Criteria

  1. Org admin creates project “Sensitive Research”
  2. Admin adds User A as Editor, User B as Viewer
  3. User A logs in:
    • Sees “Sensitive Research” in sidebar
    • Can upload files, run analyses, edit reports
  4. User B logs in:
    • Sees “Sensitive Research” in sidebar with eye icon
    • Can view files and reports
    • Upload button is hidden, edit buttons are disabled
  5. User C (not a member) logs in:
    • Does NOT see “Sensitive Research” in sidebar
  6. Org admin can see and manage all projects regardless of membership