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:
2026-08-05 17:36:14 -07:00
parent 3cccdf1c4b
commit 4ace2afb1c
19 changed files with 1769 additions and 643 deletions

View File

@@ -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)}}