Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag
Acts on the site comments from 8/3 plus the follow-ups. Foundation work first — four of the comments all needed the project team to resolve to real user accounts. Permissions vs project role (new) - User.role is now the PERMISSIONS role: admin | project_admin | project_user. project_admin may delete work packages, change a SOP after it is complete, and delete a project; project_user may not (archiving a WP is still open to them). Enforced by require_project_admin() server-side; the UI only hides dead ends. - New User.project_role holds the person's JOB FUNCTION on the project. It grants nothing — it feeds the SOP team pickers and notification routing. - Admin console shows both columns and explains the difference. Migration rewrites the legacy role 'user' to 'project_user'. - Deleting a project was previously open to any member and unaudited; it now needs project_admin and writes an audit event. ProjectData.remove no longer drops the project from the local cache when the server refuses. SOP project team from user accounts - PM/APM/CM/QM and additional team members are pickers over the project's members, storing the account id next to the display name. A name from an older SOP with no matching account is kept and flagged rather than dropped. - The WP Creator lists the SOP team first in the Owner picker, and a new package defaults to whoever is creating it. Critical constraints - SOP constraints carry a Critical flag; buildConstraints() now copies the whole definition through to the package (it previously reduced them to names, losing description too), and critical rows are marked in the WP form. The email on reopen-after-release is wave 3. Password reset by email - login.html gains Forgot password and a set-a-new-password view, offered only when the server reports email is actually configured. - Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version, sent immediately rather than through the notifications outbox so a reset link is never persisted. Identical response for unknown accounts; per-account send cooldown; a completed reset clears any login lockout. - Session and reset tokens are no longer interchangeable. BIM kill-switch - New admin Features card with bim_enabled, OFF by default. The SOP creator hides the BIM section and the Creator treats every package as install-only while it is off; a SOP that already has BIM keeps its data untouched. Verified with two throwaway-database test scripts: 44 checks on the permissions matrix and token handling, 22 on the reset flow end-to-end against a local SMTP sink (real message captured, link extracted and used). Front-end files parse-checked in headless Chrome. Not yet exercised in a browser against a real login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""permissions roles + project (job function) role
|
||||
|
||||
Adds `users.project_role` (job function on the project — carries no permissions)
|
||||
and migrates the permissions vocabulary: the legacy role 'user' becomes
|
||||
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
|
||||
granted explicitly from the admin console.
|
||||
|
||||
Revision ID: b41c7ae90d52
|
||||
Revises: 57dec34f11cb
|
||||
Create Date: 2026-08-03 15:12:04.118322
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b41c7ae90d52'
|
||||
down_revision = '57dec34f11cb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# server_default backfills existing rows (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('project_role', sa.String(length=120),
|
||||
nullable=False, server_default=''))
|
||||
# Legacy 'user' means exactly what 'project_user' means now.
|
||||
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Fold the new role back onto the legacy value so an older build still reads
|
||||
# the table. A project_admin loses its elevated rights on downgrade.
|
||||
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
|
||||
op.drop_column('users', 'project_role')
|
||||
218
server/app.py
218
server/app.py
@@ -12,6 +12,7 @@ import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from time import monotonic
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -111,7 +112,7 @@ def check_id(v: Optional[str]) -> None:
|
||||
# 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":
|
||||
if auth.is_admin(user):
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
|
||||
@@ -120,7 +121,7 @@ def accessible_project_ids(db: Session, user: "models.User"):
|
||||
|
||||
|
||||
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||
if user.role == "admin":
|
||||
if auth.is_admin(user):
|
||||
return
|
||||
if not project_id:
|
||||
# Non-admins may not read/mutate resources with no project assignment
|
||||
@@ -136,6 +137,19 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
|
||||
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||
|
||||
|
||||
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
||||
what: str = "this action") -> None:
|
||||
"""Destructive / baseline-changing operations: deleting a work package or a
|
||||
project, and editing a SOP that has already been completed. Requires project
|
||||
access AND the project_admin (or admin) permissions role."""
|
||||
require_project_access(db, user, project_id)
|
||||
if not auth.is_project_admin(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"{what} requires the Project Admin permissions role",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -174,7 +188,7 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) ->
|
||||
u = db.get(models.User, user_id)
|
||||
if not u or not u.is_active:
|
||||
raise HTTPException(status_code=400, detail="Assignee is not a valid user")
|
||||
if u.role == "admin":
|
||||
if auth.is_admin(u):
|
||||
return
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
@@ -251,6 +265,7 @@ class SettingsIn(BaseModel):
|
||||
from_addr: Optional[str] = None
|
||||
from_name: Optional[str] = None
|
||||
app_base_url: Optional[str] = None
|
||||
bim_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
@@ -296,7 +311,21 @@ class NewUserIn(BaseModel):
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||
project_role: str = "" # job function on the project (no permissions)
|
||||
|
||||
|
||||
class ProjectRoleIn(BaseModel):
|
||||
project_role: str = ""
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
@@ -313,7 +342,7 @@ class ActiveIn(BaseModel):
|
||||
|
||||
|
||||
class RoleIn(BaseModel):
|
||||
role: str # 'admin' | 'user'
|
||||
role: str # permissions role — see auth.ROLES
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
@@ -367,6 +396,115 @@ def logout(response: Response):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Self-service password reset (needs email switched on) ──────────────────────
|
||||
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
|
||||
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
|
||||
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
|
||||
# restart — deliberately simple; the token expiry is the real control.
|
||||
_reset_last: dict[str, float] = {}
|
||||
|
||||
|
||||
def _reset_throttled(request: Request, username: str) -> bool:
|
||||
now = monotonic()
|
||||
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
|
||||
prev = _reset_last.get(key)
|
||||
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
|
||||
return True
|
||||
_reset_last[key] = now
|
||||
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
|
||||
cutoff = now - RESET_COOLDOWN_SECONDS
|
||||
for k in [k for k, t in _reset_last.items() if t < cutoff]:
|
||||
_reset_last.pop(k, None)
|
||||
return False
|
||||
|
||||
|
||||
def reset_body(user: "models.User", link: str, minutes: int) -> str:
|
||||
# No account detail beyond the username, and no customer data — same rule as
|
||||
# the assignment mail. The link is the only sensitive thing in here.
|
||||
who = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A password reset was requested for your Work Package Suite account "
|
||||
f"({user.username}).\n\n"
|
||||
f"Set a new password:\n{link}\n\n"
|
||||
f"The link expires in {minutes} minutes and can only be used once. "
|
||||
f"If you didn't request this, you can ignore this email — your current "
|
||||
f"password still works.\n"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/auth/reset-available")
|
||||
def reset_available(db: Session = Depends(get_db)):
|
||||
"""Whether the login page should offer 'Forgot password'. Self-service reset
|
||||
depends entirely on outbound email, so it's off unless email is enabled AND
|
||||
SMTP is configured — otherwise the only route is an admin reset."""
|
||||
s = notify.get_settings(db)
|
||||
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
|
||||
|
||||
|
||||
@app.post("/api/auth/forgot-password")
|
||||
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
|
||||
"""Email a reset link. Always returns the same 200 response whether or not the
|
||||
account exists — this endpoint is unauthenticated, so it must not become a
|
||||
username/email oracle. Failures are recorded in the audit log instead."""
|
||||
s = notify.get_settings(db)
|
||||
if not (s.get("email_enabled") and notify.smtp_ready(s)):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
|
||||
)
|
||||
if _reset_throttled(request, body.username):
|
||||
# Same shape as the success response — no oracle, no mail bomb.
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
user = auth.find_user(db, body.username)
|
||||
if user and user.is_active and user.email:
|
||||
base = (s.get("app_base_url") or "").rstrip("/")
|
||||
token = auth.create_reset_token(user)
|
||||
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
|
||||
sent = notify.send_now(
|
||||
db, user.email,
|
||||
"Work Package Suite — reset your password",
|
||||
reset_body(user, link, auth.RESET_MINUTES),
|
||||
)
|
||||
log_event(db, user.username, "password_reset_requested", "user", user.id,
|
||||
summary=user.username, detail={"emailed": bool(sent)})
|
||||
db.commit()
|
||||
else:
|
||||
# Log the miss for the admin's benefit; the caller can't tell the difference.
|
||||
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
|
||||
summary=(body.username or "")[:200],
|
||||
detail={"reason": "no account, inactive, or no email on file"})
|
||||
db.commit()
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
|
||||
|
||||
@app.post("/api/auth/reset-password")
|
||||
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
||||
"""Complete a reset using the emailed token. The token carries the user's
|
||||
token_version, and finishing a reset bumps it — so the link is single-use and
|
||||
every existing session for that account is signed out."""
|
||||
claims = auth.decode_reset_token(body.token or "")
|
||||
if not claims:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
|
||||
# A completed reset also clears any login lockout — the person has proven
|
||||
# control of the mailbox, so there's nothing left to throttle.
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
"""Who is logged in. The frontend guard calls this on every page load."""
|
||||
@@ -401,8 +539,8 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
if body.role not in auth.ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
||||
if auth.find_user(db, body.username):
|
||||
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
||||
u = models.User(
|
||||
@@ -412,9 +550,11 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
project_role=body.project_role.strip()[:120],
|
||||
)
|
||||
db.add(u)
|
||||
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
|
||||
detail={"role": u.role, "project_role": u.project_role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
@@ -450,20 +590,22 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/role")
|
||||
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Change a user's role (admin ↔ user). Admins can do this at any time.
|
||||
"""Change a user's PERMISSIONS role (admin / project_admin / project_user).
|
||||
Their job function on the project is separate — see set_user_project_role.
|
||||
|
||||
Guards: you can't change your own role (avoids self-lockout), and the last
|
||||
remaining admin can't be demoted (keeps the app manageable)."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
if body.role not in auth.ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot change your own role")
|
||||
if u.role == "admin" and body.role != "admin":
|
||||
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
|
||||
other_admins = db.scalars(
|
||||
select(models.User.id).where(
|
||||
(models.User.role == "admin")
|
||||
(models.User.role == auth.ROLE_ADMIN)
|
||||
& (models.User.id != u.id)
|
||||
& (models.User.is_active.is_(True))
|
||||
)
|
||||
@@ -479,6 +621,23 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/project-role")
|
||||
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Set a user's job function on the project (Project Manager, Superintendent,
|
||||
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
|
||||
and notification routing read, so it's worth keeping accurate."""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
old = u.project_role or ""
|
||||
u.project_role = (body.project_role or "").strip()[:120]
|
||||
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
|
||||
detail={"from": old, "to": u.project_role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/auth/users/{user_id}")
|
||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
@@ -545,7 +704,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
|
||||
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
|
||||
db.commit()
|
||||
# A project created by a non-admin auto-grants its creator access.
|
||||
if is_new and user.role != "admin":
|
||||
if is_new and not auth.is_admin(user):
|
||||
grant_project_access(db, user.id, proj.id)
|
||||
db.commit()
|
||||
db.refresh(proj)
|
||||
@@ -573,7 +732,10 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
|
||||
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)
|
||||
# Cascades to every SOP and work package on the project — Project Admin only.
|
||||
require_project_admin(db, user, proj.id, "Deleting a project")
|
||||
log_event(db, user, "deleted", "project", proj.id, project_id=proj.id,
|
||||
summary=(proj.name or proj.number or proj.id))
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
@@ -587,6 +749,11 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
|
||||
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)
|
||||
# The SOP is the project's baseline: once it's been completed, changing it
|
||||
# is a Project Admin action. Authoring and revising a draft is open to any
|
||||
# project member, including marking it complete the first time.
|
||||
if sop.complete:
|
||||
require_project_admin(db, user, sop.project_id, "Changing a completed SOP")
|
||||
is_new = sop is None
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
@@ -645,7 +812,7 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
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)
|
||||
require_project_admin(db, user, sop.project_id, "Deleting a SOP")
|
||||
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
|
||||
summary=(sop.name or sop.number or sop.id))
|
||||
db.delete(sop)
|
||||
@@ -816,7 +983,9 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), 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)
|
||||
# Deleting a work package is irreversible — Project Admin only. A project_user
|
||||
# who wants one out of the way can archive it instead (reversible).
|
||||
require_project_admin(db, user, wp.project_id, "Deleting a work package")
|
||||
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
db.delete(wp)
|
||||
@@ -924,6 +1093,13 @@ def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Sess
|
||||
return notify.public_settings(db)
|
||||
|
||||
|
||||
@app.get("/api/app-flags")
|
||||
def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Feature flags every signed-in page reads (e.g. whether the BIM/VDC tooling
|
||||
is switched on). No secrets — safe for any authenticated user."""
|
||||
return notify.app_flags(db)
|
||||
|
||||
|
||||
@app.put("/api/settings")
|
||||
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
@@ -955,7 +1131,7 @@ def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require
|
||||
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Notification)
|
||||
if not (all and user.role == "admin"):
|
||||
if not (all and auth.is_admin(user)):
|
||||
stmt = stmt.where(models.Notification.user_id == user.id)
|
||||
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
|
||||
return [n.to_dict() for n in rows]
|
||||
@@ -967,13 +1143,15 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
|
||||
require_project_access(db, user, project_id)
|
||||
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
|
||||
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
|
||||
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all()
|
||||
admins = db.scalars(select(models.User).where(models.User.role == auth.ROLE_ADMIN)).all()
|
||||
out, seen = [], set()
|
||||
for u in list(members) + list(admins):
|
||||
if u.id in seen or not u.is_active:
|
||||
continue
|
||||
seen.add(u.id)
|
||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email})
|
||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||
"email": u.email, "project_role": u.project_role or "",
|
||||
"role": auth.normalize_role(u.role)})
|
||||
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
@@ -16,7 +16,19 @@ Security model:
|
||||
set; if it is missing we fall back to a random per-process key (which logs a
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Roles: 'admin' (may manage users) and 'user'.
|
||||
Permissions roles (`User.role`) — distinct from a person's job function on the
|
||||
project, which lives in `User.project_role` and grants nothing:
|
||||
• admin application administrator: user administration, app settings,
|
||||
and implicit access to every project.
|
||||
• project_admin within their assigned projects: may delete work packages,
|
||||
modify a SOP after it has been completed, and delete projects.
|
||||
• project_user normal member: creates and edits work packages, authors a SOP
|
||||
up to completion. May NOT delete WPs or change a completed SOP.
|
||||
|
||||
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||
to the account's address. It is single-use by construction — it embeds the user's
|
||||
`token_version`, which is bumped when the password changes, so a used or
|
||||
superseded link stops validating.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
@@ -39,6 +51,48 @@ COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# How long an emailed password-reset link stays valid.
|
||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_PROJECT_ADMIN = "project_admin"
|
||||
ROLE_PROJECT_USER = "project_user"
|
||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
ROLE_LABELS = {
|
||||
ROLE_ADMIN: "Administrator",
|
||||
ROLE_PROJECT_ADMIN: "Project Admin",
|
||||
ROLE_PROJECT_USER: "Project User",
|
||||
}
|
||||
# Job functions offered in the admin console. Free text underneath, so a project
|
||||
# can use a title that isn't on this list.
|
||||
PROJECT_ROLES = (
|
||||
"Project Manager", "Assistant Project Manager", "Construction Manager",
|
||||
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
|
||||
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
|
||||
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
|
||||
)
|
||||
|
||||
|
||||
def normalize_role(role: Optional[str]) -> str:
|
||||
"""Map a stored/incoming role onto the current vocabulary.
|
||||
|
||||
Accounts created before permissions roles existed carry the legacy value
|
||||
'user', which means exactly what 'project_user' means now."""
|
||||
r = (role or "").strip()
|
||||
if r == "user":
|
||||
return ROLE_PROJECT_USER
|
||||
return r if r in ROLES else ROLE_PROJECT_USER
|
||||
|
||||
|
||||
def is_admin(user: "models.User") -> bool:
|
||||
return normalize_role(user.role) == ROLE_ADMIN
|
||||
|
||||
|
||||
def is_project_admin(user: "models.User") -> bool:
|
||||
"""True for app admins and project admins — the two roles allowed to delete
|
||||
work packages and change a completed SOP."""
|
||||
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
@@ -134,11 +188,43 @@ def create_token(user: "models.User") -> str:
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Return the token claims if the signature and expiry are valid, else None."""
|
||||
"""Return the token claims if the signature and expiry are valid, else None.
|
||||
Session cookies only — a token of any other type is rejected."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
# A password-reset token must never be usable as a session cookie.
|
||||
if claims.get("typ"):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def create_reset_token(user: "models.User") -> str:
|
||||
"""Short-lived, single-use token for an emailed password-reset link.
|
||||
|
||||
Single-use falls out of `ver`: completing a reset bumps the user's
|
||||
token_version, so the link (and any older link) no longer validates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "pwreset",
|
||||
"sub": user.id,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=RESET_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_reset_token(token: str) -> Optional[dict]:
|
||||
"""Claims for a valid, unexpired reset token, else None."""
|
||||
try:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if claims.get("typ") != "pwreset":
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
@@ -206,7 +292,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
|
||||
|
||||
|
||||
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||
if user.role != "admin":
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
@@ -121,8 +121,15 @@ class WorkPackage(Base):
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with; `role` is
|
||||
either 'admin' (can manage users) or 'user'."""
|
||||
hash (see server/auth.py). `username` is what people sign in with.
|
||||
|
||||
Two independent notions of "role", deliberately separate:
|
||||
• role the PERMISSIONS role — what the account may do in the app.
|
||||
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
|
||||
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
||||
Superintendent, QA/QC, …). Carries no permissions; it's what
|
||||
the SOP team pickers and notification routing read.
|
||||
"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
@@ -130,7 +137,9 @@ class User(Base):
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -146,7 +155,8 @@ class User(Base):
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
return {
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
|
||||
"full_name": self.full_name, "role": self.role,
|
||||
"project_role": self.project_role or "", "is_active": self.is_active,
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,19 @@ DEFAULTS = {
|
||||
"from_addr": "",
|
||||
"from_name": "Work Package Suite",
|
||||
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
|
||||
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
|
||||
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
||||
# install-only, so no project can be put on the BIM path by accident.
|
||||
"bim_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||
# are safe for any authenticated user to read (feature flags + whether
|
||||
# self-service password reset can work at all).
|
||||
PUBLIC_KEYS = ("bim_enabled",)
|
||||
|
||||
|
||||
def get_settings(db: Session) -> dict:
|
||||
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||
s = dict(DEFAULTS)
|
||||
@@ -65,6 +75,16 @@ def public_settings(db: Session) -> dict:
|
||||
return s
|
||||
|
||||
|
||||
def app_flags(db: Session) -> dict:
|
||||
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
|
||||
`password_reset_enabled` tells the login page whether a self-service reset can
|
||||
actually deliver mail — there's no point offering the link otherwise."""
|
||||
s = get_settings(db)
|
||||
out = {k: s.get(k) for k in PUBLIC_KEYS}
|
||||
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
|
||||
return out
|
||||
|
||||
|
||||
def smtp_ready(s: dict) -> bool:
|
||||
return bool(s.get("smtp_host") and s.get("from_addr"))
|
||||
|
||||
@@ -91,6 +111,22 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
srv.send_message(msg)
|
||||
|
||||
|
||||
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
|
||||
"""Send one email immediately, outside the outbox. Used for password resets —
|
||||
a reset link must never sit in a queue, and it must not be persisted in the
|
||||
notifications table where an admin could read it and take over the account.
|
||||
Returns True if it went out."""
|
||||
s = get_settings(db)
|
||||
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
|
||||
return False
|
||||
try:
|
||||
send_email(s, to_addr, subject, body)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
|
||||
log.warning("password-reset email to %s failed: %s", to_addr, e)
|
||||
return False
|
||||
|
||||
|
||||
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||
|
||||
Reference in New Issue
Block a user