Stage 5: Project-Level RBAC
Goal
Control who can access which projects and what they can do within them.
Key Design Decisions
- Simple roles: Viewer (read-only) and Editor (full CRUD)
- Org admin override: Org admins/owners automatically have full access to all projects
- Explicit membership: Regular users must be added to projects to see them
- 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>
);
}
Sidebar with Access Indicator
// 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 modelapi/migrations/versions/xxx_add_project_memberships.py- Migrationapi/src/deps.py- Add access control functionsapi/src/routers/projects.py- Add membership endpoints, update listapi/src/routers/files.py- Add access checksapi/src/routers/analyses.py- Add access checks
Frontend
client/src/components/ProjectMembersDialog.jsx- Newclient/src/components/AddMemberForm.jsx- Newclient/src/contexts/ProjectContext.jsx- Include user_role in project dataclient/src/components/layout/Sidebar.jsx- Show access indicatorclient/src/pages/Files.jsx- Conditional edit actionsclient/src/pages/Upload.jsx- Check edit accessclient/src/pages/MultiAnalysis.jsx- Check edit access
Demo Criteria
- Org admin creates project “Sensitive Research”
- Admin adds User A as Editor, User B as Viewer
- User A logs in:
- Sees “Sensitive Research” in sidebar
- Can upload files, run analyses, edit reports
- 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
- User C (not a member) logs in:
- Does NOT see “Sensitive Research” in sidebar
- Org admin can see and manage all projects regardless of membership