Per-project access control + UI/feedback/admin refinements
Access control: - project_members table; non-admins only see/operate on assigned projects (enforced across projects, SOPs, work packages — 403 else), admins bypass. Creating a project auto-grants its creator access. - Admin API to get/set a user's project assignments, plus a checkbox assignment dialog in the Admin Console user list. UI / workflow: - Login page: drop the "Prime Controls" wordmark next to the logo. - SOP tool: remove emoji icons from buttons and nav tabs. - Rename "Step Comments" to "Feedback"; the author auto-populates (read-only) from the signed-in user. - Move usage-log viewing to the Admin Console; add an admin card that lists all feedback/comments (who, what, page + step, when). - Sample project name -> "Micron FMCS Install (sample)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
server/app.py
141
server/app.py
@@ -56,6 +56,54 @@ def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# ── Per-project access control ─────────────────────────────────────────────────
|
||||
# A non-admin user may only touch projects they're a member of (project_members).
|
||||
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
|
||||
# gated. List endpoints are scoped to accessible projects; single-resource and
|
||||
# mutating endpoints raise 403 on no access.
|
||||
def accessible_project_ids(db: Session, user: "models.User"):
|
||||
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
|
||||
if user.role == "admin":
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||
if user.role == "admin" or project_id is None:
|
||||
return
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user.id)
|
||||
& (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||
|
||||
|
||||
def scope_to_access(stmt, column, db: Session, user: "models.User"):
|
||||
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is None:
|
||||
return stmt
|
||||
return stmt.where(column.in_(ids))
|
||||
|
||||
|
||||
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
||||
"""Add a (user, project) membership if it isn't already there."""
|
||||
exists = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user_id)
|
||||
& (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
)
|
||||
if not exists:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
@@ -143,6 +191,10 @@ class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
||||
@@ -249,10 +301,43 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@app.get("/api/auth/users/{user_id}/projects")
|
||||
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Which projects a user is assigned to, plus the full project list for the
|
||||
assignment UI. (Admins implicitly access every project regardless.)"""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
return {
|
||||
"user": u.to_dict(),
|
||||
"assigned": list(assigned),
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/auth/users/{user_id}/projects")
|
||||
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Replace a user's project assignments with the given set."""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||
for pid in valid:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
||||
db.commit()
|
||||
return {"assigned": sorted(valid)}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
is_new = proj is None
|
||||
if not is_new:
|
||||
require_project_access(db, user, proj.id)
|
||||
if proj is None:
|
||||
proj = models.Project(id=body.id or gen_id("proj"))
|
||||
db.add(proj)
|
||||
@@ -265,29 +350,36 @@ def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
db.commit()
|
||||
# A project created by a non-admin auto-grants its creator access.
|
||||
if is_new and user.role != "admin":
|
||||
grant_project_access(db, user.id, proj.id)
|
||||
db.commit()
|
||||
db.refresh(proj)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
|
||||
rows = db.scalars(stmt).all()
|
||||
return [p.summary() for p in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
@@ -295,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, body.project_id)
|
||||
sop = db.get(models.Sop, body.id) if body.id else None
|
||||
if sop is not None:
|
||||
require_project_access(db, user, sop.project_id)
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
@@ -312,21 +407,23 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.get("/api/sops")
|
||||
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def list_sops(project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||
return [s.summary() for s in rows]
|
||||
|
||||
|
||||
@app.get("/api/sops/latest")
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="No SOP found")
|
||||
@@ -334,18 +431,20 @@ def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Quer
|
||||
|
||||
|
||||
@app.get("/api/sops/{sop_id}")
|
||||
def get_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/sops/{sop_id}")
|
||||
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
@@ -353,8 +452,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, body.project_id)
|
||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||
if wp is not None:
|
||||
require_project_access(db, user, wp.project_id)
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
@@ -378,6 +480,7 @@ def list_wps(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.WorkPackage)
|
||||
@@ -389,12 +492,13 @@ def list_wps(
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if status:
|
||||
stmt = stmt.where(models.WorkPackage.status == status)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
return [w.summary() for w in rows]
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
@@ -403,6 +507,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt).all()
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
@@ -436,30 +541,33 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
|
||||
|
||||
@app.get("/api/wps/{wp_id}")
|
||||
def get_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/wps/{wp_id}")
|
||||
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/issue")
|
||||
def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Release a Work Package to the field. Refuses if any constraint is still
|
||||
open (the AWP release gate)."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
constraints = (wp.data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||
if open_names:
|
||||
@@ -472,10 +580,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/status")
|
||||
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)):
|
||||
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
|
||||
@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .db import Base
|
||||
|
||||
@@ -137,6 +137,23 @@ class User(Base):
|
||||
}
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""Which users may access which projects. A user sees/operates on a project
|
||||
only if a row links them to it (admins bypass this entirely). One row per
|
||||
(user, project) pair."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user