Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.
server/auth.py, server/app.py
New permissions role `project_super_user`, between admin and project_admin:
everything a project admin may do, plus user administration SCOPED to the
projects they hold the role on. Four limits make it safe to hand out, all
enforced server-side:
* Scope comes from projects, not the job title. It resolves per membership
(managed_project_ids), so an ordinary account can hold it on one job via
ProjectMember.role, and a super user demoted on one job administers
nobody there. No projects, no authority.
* Account-level changes (password, disable, rename, permissions, delete)
require EXCLUSIVE scope: refused when the target is also on a project the
caller does not administer, because those changes are global. The
directory renders such rows read-only with the reason.
* No admin or super-user targets, and neither role can be granted by a
super user -- that is the line that stops it becoming app-wide control.
* PUT .../projects rebuilds only the caller's own slice; memberships on
projects they do not administer are left untouched. A payload that simply
omits them must not cut someone off a job the caller cannot see.
Creating requires naming at least one of your own projects: an account with
none would be one the creator instantly cannot manage.
/api/auth/users is now scoped rather than admin-only, and carries a per-row
`manageable` verdict plus the reason. Non-managers get a contact card only --
a project user has no business reading colleagues' login history. New
/api/auth/user-scope tells the page what it may offer. Administrative
password resets are now audited; they were the one account change that left
no trace. Settings, feature flags and the auto-add rule stay admin-only.
While here: one definition of "is a user manager", derived from the managed
set. An account-role-only version disagreed with the scoped one and locked
per-project super users out of routes they were entitled to.
html/users.html, html/users.js
The directory: three renderings from one page -- admin (everything), super
user (controls per row, read-only where scope is shared), everyone else (a
read-only directory of the people on their own projects).
html/console.css, html/console-util.js
Extracted from admin.html/admin.js so both console pages share them. A
divergent jsq() is an XSS and a divergent role list offers permissions the
server refuses, so neither may exist twice.
html/wp-sidenav.{js,css}
Global nav drawer, role-gated, carrying ?project= across links. Mounted on
the field view (which had no way to anywhere) plus both console pages.
No migration: users.role is already String(20) and the new value fits.
Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.
server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
469
server/app.py
469
server/app.py
@@ -163,7 +163,9 @@ def require_project_admin(db: Session, user: "models.User", project_id: Optional
|
||||
project, and editing a SOP that has already been completed. Requires project
|
||||
access AND Project Admin *on that project*."""
|
||||
require_project_access(db, user, project_id)
|
||||
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
|
||||
if effective_role(db, user, project_id) not in (
|
||||
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"{what} requires the Project Admin role on this project",
|
||||
@@ -247,6 +249,163 @@ def add_default_members(db: Session, project_id: str, actor) -> list[str]:
|
||||
return added
|
||||
|
||||
|
||||
# ── User-administration scope ──────────────────────────────────────────────────
|
||||
# User administration used to be one thing: an app admin did all of it. It is now
|
||||
# two, because a project admin has to be able to staff their own job without an app
|
||||
# admin on the phone. An app admin still manages every account; a PROJECT SUPER USER
|
||||
# manages the accounts on the projects they hold that role on.
|
||||
#
|
||||
# Three questions, deliberately separate, because they have different answers:
|
||||
# managed_project_ids which projects do I administer the users of?
|
||||
# visible_user_ids whose entry may I SEE in the directory?
|
||||
# manage_user_problem may I change this account? (much narrower than seeing it)
|
||||
def managed_project_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
|
||||
"""Projects where `caller` may administer user accounts. None means every project
|
||||
(an app admin). Read per-membership so the per-project override decides: a super
|
||||
user demoted to plain member on one job does not administer its users, and an
|
||||
ordinary account made super user on one job does administer that one."""
|
||||
if auth.is_admin(caller):
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(models.ProjectMember).where(models.ProjectMember.user_id == caller.id)
|
||||
).all()
|
||||
account_role = auth.normalize_role(caller.role)
|
||||
out = set()
|
||||
for r in rows:
|
||||
role = auth.normalize_role(r.role) if (r.role or "").strip() else account_role
|
||||
if role == auth.ROLE_PROJECT_SUPER:
|
||||
out.add(r.project_id)
|
||||
return out
|
||||
|
||||
|
||||
def is_user_manager(db: Session, user: "models.User") -> bool:
|
||||
"""May this account administer users at all? THE one definition — derived from the
|
||||
managed set, never from the account role alone, because the super-user role can be
|
||||
held on a single project (ProjectMember.role) by an otherwise ordinary account.
|
||||
|
||||
Falls out of it that a super user with no project memberships manages nobody,
|
||||
which is right: the authority comes from the jobs, not the job title."""
|
||||
managed = managed_project_ids(db, user)
|
||||
return managed is None or bool(managed)
|
||||
|
||||
|
||||
def require_user_manager(user: "models.User" = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db)) -> "models.User":
|
||||
"""First gate on the user-administration routes: does this caller administer the
|
||||
users of ANY project? Which accounts they may then touch is a second, narrower
|
||||
check per target — require_manage_user."""
|
||||
if not is_user_manager(db, user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Managing user accounts requires the Administrator role, or Project "
|
||||
"Super User on a project",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def member_project_ids(db: Session, user_id: str) -> set[str]:
|
||||
"""Every project this user has a membership row for (no admin shortcut — this is
|
||||
the raw set, which is exactly what the scope checks need to reason about)."""
|
||||
return set(db.scalars(
|
||||
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)
|
||||
).all())
|
||||
|
||||
|
||||
def visible_user_ids(db: Session, caller: "models.User") -> Optional[set[str]]:
|
||||
"""Whose directory entry `caller` may read. None means everyone (an app admin).
|
||||
|
||||
Anyone signed in may look up the people they actually work with — their own
|
||||
projects' members — plus the app admins, who are on every project implicitly and
|
||||
are who you go to when something needs unblocking. Nobody else: the directory
|
||||
must not become a company-wide address book for a single-project subcontractor."""
|
||||
if auth.is_admin(caller):
|
||||
return None
|
||||
ids = {caller.id}
|
||||
mine = accessible_project_ids(db, caller) or set()
|
||||
if mine:
|
||||
ids |= set(db.scalars(
|
||||
select(models.ProjectMember.user_id).where(models.ProjectMember.project_id.in_(mine))
|
||||
).all())
|
||||
ids |= set(db.scalars(
|
||||
select(models.User.id).where(models.User.role == auth.ROLE_ADMIN)
|
||||
).all())
|
||||
return ids
|
||||
|
||||
|
||||
def manage_user_problem(db: Session, caller: "models.User", target: "models.User",
|
||||
cache: Optional[dict] = None) -> Optional[str]:
|
||||
"""None if `caller` may make ACCOUNT-level changes to `target` (password, name,
|
||||
permissions role, enable/disable, delete); otherwise the reason they may not, in
|
||||
words the console can show verbatim.
|
||||
|
||||
An app admin may always. A super user may only when the account sits ENTIRELY
|
||||
inside the projects they administer, and is not itself an admin or super user.
|
||||
Both limits matter:
|
||||
• Exclusive scope, because these changes are global. Resetting a password or
|
||||
disabling an account reaches every project that person is on, so a super user
|
||||
must not be able to reach into a job they don't run by way of a shared member.
|
||||
• No admin/super targets, because otherwise the role could be used to take over
|
||||
a peer's account and inherit their scope.
|
||||
Project-scoped changes (adding someone to MY project, their role THERE) are not
|
||||
account-level and are checked against `managed_project_ids` instead.
|
||||
|
||||
`cache` lets a caller judging a whole page of users hand in the two lookups this
|
||||
needs ('managed', and 'members' as {user_id: {project_id}}) so the verdict for
|
||||
thirty rows costs two queries instead of sixty. The rule itself lives only here."""
|
||||
if auth.is_admin(caller):
|
||||
return None
|
||||
cache = cache if cache is not None else {}
|
||||
managed = cache.get("managed")
|
||||
if managed is None:
|
||||
managed = cache["managed"] = managed_project_ids(db, caller) or set()
|
||||
if not managed:
|
||||
return ("You don't administer the users of any project — that needs the Project "
|
||||
"Super User role on the project")
|
||||
if auth.normalize_role(target.role) in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER):
|
||||
return "Only an application administrator can change an Administrator or Project Super User account"
|
||||
members = cache.get("members")
|
||||
theirs = members.get(target.id, set()) if members is not None else member_project_ids(db, target.id)
|
||||
if not theirs:
|
||||
return ("This account isn't on any project, so only an application administrator "
|
||||
"can change it")
|
||||
outside = theirs - managed
|
||||
if outside:
|
||||
return (f"{target.username} is also on {len(outside)} project(s) you don't administer — "
|
||||
"account changes there have to come from an application administrator. "
|
||||
"You can still change their access and role on your own projects.")
|
||||
return None
|
||||
|
||||
|
||||
def require_manage_user(db: Session, caller: "models.User", target: "models.User") -> None:
|
||||
problem = manage_user_problem(db, caller, target)
|
||||
if problem:
|
||||
raise HTTPException(status_code=403, detail=problem)
|
||||
|
||||
|
||||
def require_see_user(db: Session, caller: "models.User", target: "models.User") -> None:
|
||||
"""404, not 403: whether an account exists outside your projects is itself not
|
||||
yours to learn, and a 403 would confirm the username."""
|
||||
visible = visible_user_ids(db, caller)
|
||||
if visible is not None and target.id not in visible:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
|
||||
def grantable_roles(caller: "models.User") -> tuple:
|
||||
"""Permissions roles `caller` may hand out. A super user may staff their job with
|
||||
project admins and project users — never another admin or super user, which is the
|
||||
line that keeps the role from being a route to app-wide control."""
|
||||
if auth.is_admin(caller):
|
||||
return auth.ROLES
|
||||
return (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
|
||||
|
||||
def load_target_user(db: Session, user_id: str) -> "models.User":
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return u
|
||||
|
||||
|
||||
# ── Audit trail ────────────────────────────────────────────────────────────────
|
||||
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
|
||||
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
|
||||
@@ -392,6 +551,10 @@ class NewUserIn(BaseModel):
|
||||
email: str = ""
|
||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||
project_role: str = "" # job function on the project (no permissions)
|
||||
# Projects to put the new account on straight away. Optional for an app admin
|
||||
# (who can assign later); REQUIRED for a super user, whose authority over an
|
||||
# account comes from the projects it is on — see create_user.
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectRoleIn(BaseModel):
|
||||
@@ -603,8 +766,11 @@ def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
||||
|
||||
@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."""
|
||||
return {"user": user.to_dict()}
|
||||
"""Who is logged in. The frontend guard calls this on every page load.
|
||||
|
||||
`role` is normalized here so no page has to know that a pre-roles account stores
|
||||
'user' where it now means 'project_user'."""
|
||||
return {"user": {**user.to_dict(), "role": auth.normalize_role(user.role)}}
|
||||
|
||||
|
||||
# ── Display preferences (self-service) ─────────────────────────────────────────
|
||||
@@ -667,22 +833,122 @@ def change_password(body: PasswordChangeIn, request: Request, response: Response
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration (admin only) ────────────────────────────────────────────
|
||||
# ── User administration ─────────────────────────────────────────────────────────
|
||||
# Two kinds of caller reach these routes: an app admin, who manages every account,
|
||||
# and a Project Super User, who manages the accounts on the projects they administer.
|
||||
# Every route therefore asks TWO questions — does this role carry user administration
|
||||
# (require_user_manager), and may it touch THIS account (require_manage_user) —
|
||||
# and the read route asks a third, wider one (visible_user_ids) because looking a
|
||||
# colleague up is not the same as being able to change them.
|
||||
def directory_entry(db: Session, u: "models.User", caller: "models.User",
|
||||
counts: Optional[dict] = None, cache: Optional[dict] = None) -> dict:
|
||||
"""One row of the user directory, cut to what `caller` is entitled to see.
|
||||
|
||||
A manager gets the administrative record (last login, the auto-add flags, and a
|
||||
`manageable` verdict with the reason when it's no). Everyone else gets the contact
|
||||
card only — a project user has no business reading their colleagues' login history
|
||||
out of a page whose job is "who is on this project and how do I reach them"."""
|
||||
if not is_user_manager(db, caller):
|
||||
return {
|
||||
"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email,
|
||||
"role": auth.normalize_role(u.role), "project_role": u.project_role or "",
|
||||
"is_active": u.is_active, "manageable": False,
|
||||
}
|
||||
problem = manage_user_problem(db, caller, u, cache)
|
||||
n = None if counts is None else counts.get(u.id, 0)
|
||||
return {
|
||||
**u.to_dict(),
|
||||
"role": auth.normalize_role(u.role),
|
||||
"manageable": problem is None,
|
||||
"manage_blocked_reason": problem or "",
|
||||
"project_count": n,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/users")
|
||||
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
||||
return [u.to_dict() for u in rows]
|
||||
def list_users(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""The user directory, scoped to the caller. An app admin sees every account; a
|
||||
project member sees the people on their own projects (plus the app admins)."""
|
||||
visible = visible_user_ids(db, user)
|
||||
stmt = select(models.User).order_by(models.User.username)
|
||||
if visible is not None:
|
||||
stmt = stmt.where(models.User.id.in_(visible))
|
||||
rows = db.scalars(stmt).all()
|
||||
# One pass over the membership table serves both the project-access count and the
|
||||
# per-row "may I manage this account" verdict. The old console fetched the counts
|
||||
# with one HTTP request per user.
|
||||
counts, cache = None, None
|
||||
if is_user_manager(db, user):
|
||||
members: dict[str, set] = {}
|
||||
for uid, pid in db.execute(
|
||||
select(models.ProjectMember.user_id, models.ProjectMember.project_id)
|
||||
).all():
|
||||
members.setdefault(uid, set()).add(pid)
|
||||
counts = {uid: len(pids) for uid, pids in members.items()}
|
||||
cache = {"members": members}
|
||||
return [directory_entry(db, u, user, counts, cache) for u in rows]
|
||||
|
||||
|
||||
@app.get("/api/auth/user-scope")
|
||||
def user_scope(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""What the signed-in account may do on the user directory page, so the page can
|
||||
render the right controls instead of guessing at the rules and drawing buttons
|
||||
that 403. Advisory only — every route re-checks server-side."""
|
||||
managed = managed_project_ids(db, user)
|
||||
if managed is None:
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
else:
|
||||
rows = db.scalars(
|
||||
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
|
||||
).all() if managed else []
|
||||
manager = managed is None or bool(managed)
|
||||
return {
|
||||
"can_manage_users": manager,
|
||||
"scope": "all" if managed is None else "projects",
|
||||
"role": auth.normalize_role(user.role),
|
||||
"grantable_roles": list(grantable_roles(user)) if manager else [],
|
||||
"grantable_project_roles": list(
|
||||
auth.PROJECT_SCOPED_ROLES if auth.is_admin(user)
|
||||
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
) if manager else [],
|
||||
"role_labels": auth.ROLE_LABELS,
|
||||
"project_roles": list(auth.PROJECT_ROLES),
|
||||
"managed_projects": [{"id": p.id, "name": p.name, "number": p.number,
|
||||
"archived": p.archived_at is not None} for p in rows],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
if body.role not in auth.ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
||||
allowed = grantable_roles(actor)
|
||||
if body.role not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||
if auth.find_user(db, body.username):
|
||||
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
||||
managed = managed_project_ids(db, actor)
|
||||
requested = [p for p in dict.fromkeys(body.project_ids) if p]
|
||||
for pid in requested:
|
||||
check_id(pid)
|
||||
if managed is not None:
|
||||
# A super user's authority over an account is derived from the projects that
|
||||
# account is on. Creating one with no project — or on a job they don't run —
|
||||
# would either produce an account they instantly cannot manage, or reach into
|
||||
# someone else's job. Both are refused rather than silently narrowed.
|
||||
if not requested:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Choose at least one project for the new account — you administer users per project",
|
||||
)
|
||||
outside = [p for p in requested if p not in managed]
|
||||
if outside:
|
||||
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
|
||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
|
||||
missing = [p for p in requested if p not in valid]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail="One of those projects no longer exists")
|
||||
u = models.User(
|
||||
id=gen_id("user"),
|
||||
username=body.username.strip(),
|
||||
@@ -693,54 +959,67 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
||||
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, "project_role": u.project_role})
|
||||
log_event(db, actor, "user_created", "user", u.id, summary=u.username,
|
||||
detail={"role": u.role, "project_role": u.project_role,
|
||||
"projects": len(valid)})
|
||||
for pid in requested:
|
||||
if pid in valid:
|
||||
grant_project_access(db, u.id, pid)
|
||||
if valid:
|
||||
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
|
||||
detail={"projects": len(valid), "reason": "created_with_access"})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
# An administrative password reset was the one user-account change that left no
|
||||
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
|
||||
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
|
||||
detail={"by": "administrator"})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id and not body.is_active:
|
||||
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
if u.id == actor.id and not body.is_active:
|
||||
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||
u.is_active = body.is_active
|
||||
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
||||
log_event(db, actor, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
||||
summary=u.username, detail={"is_active": bool(body.is_active)})
|
||||
db.commit()
|
||||
return u.to_dict()
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@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 PERMISSIONS role (admin / project_admin / project_user).
|
||||
Their job function on the project is separate — see set_user_project_role.
|
||||
def set_user_role(user_id: str, body: RoleIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
"""Change a user's PERMISSIONS role. 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 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:
|
||||
Guards: you can't change your own role (avoids self-lockout), the last remaining
|
||||
admin can't be demoted (keeps the app manageable), and a super user may only hand
|
||||
out the roles in `grantable_roles` — never admin or another super user."""
|
||||
allowed = grantable_roles(actor)
|
||||
if body.role not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
if u.id == actor.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot change your own role")
|
||||
if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
|
||||
other_admins = db.scalars(
|
||||
@@ -763,27 +1042,27 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
|
||||
u.auto_add_projects = False
|
||||
u.auto_add_role = ""
|
||||
detail["auto_add_cleared"] = True
|
||||
log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail)
|
||||
log_event(db, actor, "role_changed", "user", u.id, summary=u.username, detail=detail)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@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)):
|
||||
def set_user_project_role(user_id: str, body: ProjectRoleIn, actor: models.User = Depends(require_user_manager), 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")
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
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,
|
||||
log_event(db, actor, "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()
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/auto-add")
|
||||
@@ -791,13 +1070,17 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend
|
||||
"""Flag a user as a default member of every project created from here on, with
|
||||
an optional role on those projects. It only touches NEW projects — existing
|
||||
assignments stay under the admin's hand (see set_user_projects), because
|
||||
back-filling everyone onto historical jobs is never what this flag means."""
|
||||
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
back-filling everyone onto historical jobs is never what this flag means.
|
||||
|
||||
App-admin only, unlike the rest of user administration: this is a standing rule
|
||||
about every project that will ever exist, including the ones a super user has no
|
||||
part in."""
|
||||
allowed = ("",) + auth.PROJECT_SCOPED_ROLES
|
||||
role = (body.role or "").strip()
|
||||
if role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}",
|
||||
detail=f"role must be '' (inherit) or one of {', '.join(auth.PROJECT_SCOPED_ROLES)}",
|
||||
)
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
@@ -814,57 +1097,97 @@ def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depend
|
||||
|
||||
|
||||
@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)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
def delete_user(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
if u.id == actor.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
log_event(db, actor, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
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")
|
||||
def get_user_projects(user_id: str, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
"""Which projects a user is assigned to, plus the project list to choose from.
|
||||
(Admins implicitly access every project regardless of what's ticked here.)
|
||||
|
||||
A super user is shown ONLY the projects they administer, and `other_projects` says
|
||||
how many more the person is on — enough for the dialog to be honest that it is
|
||||
editing a slice of this account's access, without naming jobs that aren't theirs."""
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
|
||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
managed = managed_project_ids(db, actor)
|
||||
if managed is None:
|
||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
in_scope = rows
|
||||
else:
|
||||
projects = db.scalars(
|
||||
select(models.Project).where(models.Project.id.in_(managed)).order_by(models.Project.name)
|
||||
).all() if managed else []
|
||||
in_scope = [r for r in rows if r.project_id in managed]
|
||||
return {
|
||||
"user": u.to_dict(),
|
||||
"assigned": [r.project_id for r in rows],
|
||||
"user": directory_entry(db, u, actor),
|
||||
"assigned": [r.project_id for r in in_scope],
|
||||
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
||||
"roles": {r.project_id: (r.role or "") for r in rows},
|
||||
"roles": {r.project_id: (r.role or "") for r in in_scope},
|
||||
# Archived projects stay on this list on purpose — an existing assignment has
|
||||
# to remain visible and removable — but they're flagged so the dialog can say
|
||||
# so, rather than offering a finished job as though it were live work.
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number,
|
||||
"archived": p.archived_at is not None} for p in projects],
|
||||
"other_projects": len(rows) - len(in_scope),
|
||||
"grantable_project_roles": list(
|
||||
auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor)
|
||||
else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
# Only the two project-scoped roles make sense here: app admin is global, and
|
||||
# anything unrecognised falls back to inheriting the account's own role.
|
||||
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
def set_user_projects(user_id: str, body: ProjectAssignIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
"""Replace a user's project assignments with the given set.
|
||||
|
||||
For an app admin the given set IS the whole answer. For a super user it replaces
|
||||
only their own slice: memberships on projects they don't administer are left
|
||||
exactly as they were, because a payload that simply omits them would otherwise cut
|
||||
someone off from a job the caller can't even see."""
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
requested = [p for p in dict.fromkeys(body.project_ids) if p]
|
||||
for pid in requested:
|
||||
check_id(pid)
|
||||
managed = managed_project_ids(db, actor)
|
||||
if managed is not None:
|
||||
outside = [p for p in requested if p not in managed]
|
||||
if outside:
|
||||
raise HTTPException(status_code=403, detail="You don't administer the users of one of those projects")
|
||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(requested))).all()) if requested else set()
|
||||
# A super user may hand out the project-scoped roles below their own; only an app
|
||||
# admin can make someone a super user on a project. Anything unrecognised falls
|
||||
# back to inheriting the account's own role.
|
||||
allowed = auth.PROJECT_SCOPED_ROLES if auth.is_admin(actor) else (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||
# Rebuild only the rows this caller owns. Scoping the DELETE is the whole of the
|
||||
# "leave other jobs alone" guarantee — get it wrong and a super user's save
|
||||
# silently revokes access everywhere else.
|
||||
doomed = delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id)
|
||||
if managed is not None:
|
||||
# in_() on an empty set is a valid always-false predicate, so a caller who
|
||||
# administers nothing deletes nothing (require_manage_user already refused them).
|
||||
doomed = doomed.where(models.ProjectMember.project_id.in_(managed))
|
||||
db.execute(doomed)
|
||||
for pid in valid:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
||||
role=roles.get(pid, "")))
|
||||
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
|
||||
log_event(db, actor, "project_access_changed", "user", u.id, summary=u.username,
|
||||
detail={"projects": len(valid),
|
||||
"scope": "all" if managed is None else "managed",
|
||||
"overrides": {p: r for p, r in roles.items() if p in valid}})
|
||||
db.commit()
|
||||
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
||||
|
||||
@@ -20,11 +20,21 @@ 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_super_user
|
||||
everything a project_admin may do, plus USER ADMINISTRATION
|
||||
scoped to the projects they hold the role on: they create and
|
||||
manage the accounts on their own jobs without an app admin
|
||||
having to do it for them. They cannot reach app settings, and
|
||||
they cannot create or alter an admin / super-user account.
|
||||
• 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.
|
||||
|
||||
The user-administration SCOPE of a super user is worked out in server/app.py
|
||||
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||
membership rows — this module only decides which roles carry the power at all.
|
||||
|
||||
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
|
||||
@@ -56,14 +66,21 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_PROJECT_SUPER = "project_super_user"
|
||||
ROLE_PROJECT_ADMIN = "project_admin"
|
||||
ROLE_PROJECT_USER = "project_user"
|
||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
# Ordered most- to least-privileged; the console renders dropdowns in this order.
|
||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
ROLE_LABELS = {
|
||||
ROLE_ADMIN: "Administrator",
|
||||
ROLE_PROJECT_SUPER: "Project Super User",
|
||||
ROLE_PROJECT_ADMIN: "Project Admin",
|
||||
ROLE_PROJECT_USER: "Project User",
|
||||
}
|
||||
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
|
||||
# run the users on one job and be an ordinary member of the next. '' means "inherit
|
||||
# the account's own role" and is always allowed alongside these.
|
||||
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_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 = (
|
||||
@@ -90,9 +107,19 @@ def is_admin(user: "models.User") -> bool:
|
||||
|
||||
|
||||
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)
|
||||
"""True for the roles allowed to delete work packages and change a completed
|
||||
SOP. A super user is a project admin with user administration on top, so it is
|
||||
included here — never enumerate the two roles by hand."""
|
||||
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
|
||||
|
||||
|
||||
|
||||
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
|
||||
# super-user role can be held per project (ProjectMember.role), so the question needs
|
||||
# membership rows to answer and lives in app.py — `is_user_manager` /
|
||||
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
|
||||
# same question used to exist here and silently disagreed with the scoped one, which
|
||||
# locked per-project super users out of the routes they were entitled to.
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
@@ -297,6 +324,8 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||
"""Look up by username, case-insensitively (also matches on email)."""
|
||||
|
||||
@@ -45,8 +45,12 @@ def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||
# documented one-liners keep working; anything else has to be a current role.
|
||||
if role == "user":
|
||||
role = auth.ROLE_PROJECT_USER
|
||||
if role not in auth.ROLES:
|
||||
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
@@ -70,9 +74,10 @@ def cmd_list(args) -> None:
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
||||
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
@@ -113,7 +118,8 @@ def main() -> None:
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ class User(Base):
|
||||
|
||||
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).
|
||||
'admin' | 'project_super_user' | '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.
|
||||
@@ -189,8 +190,11 @@ class ProjectMember(Base):
|
||||
entirely). One row per (user, project) pair.
|
||||
|
||||
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||
one job and a normal Project User on another. Empty means "inherit the account's
|
||||
own role" (User.role), which is how every existing row behaves."""
|
||||
one job and a normal Project User on another, or a Project Super User (who
|
||||
administers that job's user accounts) on one job only. Empty means "inherit the
|
||||
account's own role" (User.role), which is how every existing row behaves.
|
||||
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
|
||||
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user