Bläddra i källkod

Security hardening (maziggy/bambuddy-security #1)

maziggy 2 månader sedan
förälder
incheckning
f2a3917e90

+ 18 - 2
backend/app/api/routes/groups.py

@@ -5,7 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import (
 from backend.app.core.permissions import (
     ALL_PERMISSIONS,
     ALL_PERMISSIONS,
@@ -87,6 +87,7 @@ async def list_groups(
 @router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
 async def create_group(
 async def create_group(
     group_data: GroupCreate,
     group_data: GroupCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -135,7 +136,8 @@ async def get_group(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Get a group by ID with user list."""
+    """Get a group by ID with user list. Read-only — gated on
+    ``GROUPS_READ`` only."""
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
     group = result.scalar_one_or_none()
     group = result.scalar_one_or_none()
     if not group:
     if not group:
@@ -161,6 +163,7 @@ async def get_group(
 async def update_group(
 async def update_group(
     group_id: int,
     group_id: int,
     group_data: GroupUpdate,
     group_data: GroupUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -193,6 +196,16 @@ async def update_group(
         group.description = group_data.description
         group.description = group_data.description
 
 
     if group_data.permissions is not None:
     if group_data.permissions is not None:
+        # System groups (Administrators in particular) have fixed permission
+        # sets that the app depends on — stripping them is a denial-of-
+        # service vector that even admin callers shouldn't trigger by
+        # accident through the generic edit form. Mirrors the rename block
+        # immediately above.
+        if group.is_system:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot modify permissions of system groups",
+            )
         # Validate permissions
         # Validate permissions
         invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
         invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
         if invalid_perms:
         if invalid_perms:
@@ -220,6 +233,7 @@ async def update_group(
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_group(
 async def delete_group(
     group_id: int,
     group_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -246,6 +260,7 @@ async def delete_group(
 async def add_user_to_group(
 async def add_user_to_group(
     group_id: int,
     group_id: int,
     user_id: int,
     user_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -283,6 +298,7 @@ async def add_user_to_group(
 async def remove_user_from_group(
 async def remove_user_from_group(
     group_id: int,
     group_id: int,
     user_id: int,
     user_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):

+ 14 - 3
backend/app/api/routes/users.py

@@ -12,6 +12,7 @@ from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     ALGORITHM,
     ALGORITHM,
     SECRET_KEY,
     SECRET_KEY,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_current_user_optional,
     get_password_hash,
     get_password_hash,
@@ -66,7 +67,13 @@ async def list_users(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """List all users."""
+    """List all users.
+
+    Read-only — gated on ``USERS_READ`` only. Operator-visible UIs
+    (Stats filter-by-user, Archives Print Log username column, File
+    Manager username autocomplete) consume this endpoint via custom-
+    group ``users:read`` grants without admin role. The admin-only
+    boundary lives on the write endpoints below."""
     result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     users = result.scalars().all()
     users = result.scalars().all()
     return [_user_to_response(user) for user in users]
     return [_user_to_response(user) for user in users]
@@ -76,6 +83,7 @@ async def list_users(
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
 async def create_user(
     user_data: UserCreate,
     user_data: UserCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -185,7 +193,7 @@ async def get_user(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Get a user by ID."""
+    """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
@@ -201,6 +209,7 @@ async def get_user(
 async def update_user(
 async def update_user(
     user_id: int,
     user_id: int,
     user_data: UserUpdate,
     user_data: UserUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -311,7 +320,8 @@ async def get_user_items_count(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Get count of items created by this user."""
+    """Get count of items created by this user. Read-only — gated on
+    ``USERS_READ`` only."""
     # Verify user exists
     # Verify user exists
     result = await db.execute(select(User).where(User.id == user_id))
     result = await db.execute(select(User).where(User.id == user_id))
     if not result.scalar_one_or_none():
     if not result.scalar_one_or_none():
@@ -350,6 +360,7 @@ async def get_user_items_count(
 async def delete_user(
 async def delete_user(
     user_id: int,
     user_id: int,
     delete_items: bool = Query(False, description="Delete all items created by this user"),
     delete_items: bool = Query(False, description="Delete all items created by this user"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):

+ 12 - 8
backend/app/core/auth.py

@@ -1019,13 +1019,17 @@ def require_admin_if_auth_enabled():
     key" — the inner ``admin_checker`` then treated ``None`` as auth-
     key" — the inner ``admin_checker`` then treated ``None`` as auth-
     disabled and admitted the caller. If any route had ever adopted this
     disabled and admitted the caller. If any route had ever adopted this
     dep, any API key with no scope flags set would have satisfied an
     dep, any API key with no scope flags set would have satisfied an
-    admin requirement.
-
-    Today no route uses this dep, but rather than leave the footgun
-    armed, the dep is rewritten to distinguish the two cases by
-    consulting ``is_auth_enabled`` directly and rejecting API-keyed
-    requests with 403. "Admin" requires a user-identity role, which API
-    keys do not carry.
+    admin requirement. The dep distinguishes the two cases by consulting
+    ``is_auth_enabled`` directly and rejecting API-keyed requests with
+    403. "Admin" requires a user-identity role, which API keys do not
+    carry.
+
+    Admin semantics: uses ``User.is_admin`` (``role == "admin"`` OR
+    Administrators-group membership) so a default-install operator who
+    was made admin by being added to Administrators rather than by
+    flipping the legacy role column passes. Earlier this check looked
+    only at ``role`` and would have locked group-only admins out of the
+    user-management routes once those routes started requiring it.
     """
     """
 
 
     async def admin_checker(
     async def admin_checker(
@@ -1091,7 +1095,7 @@ def require_admin_if_auth_enabled():
                     detail="Could not validate credentials",
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                     headers={"WWW-Authenticate": "Bearer"},
                 )
                 )
-            if user.role != "admin":
+            if not user.is_admin:
                 raise HTTPException(
                 raise HTTPException(
                     status_code=status.HTTP_403_FORBIDDEN,
                     status_code=status.HTTP_403_FORBIDDEN,
                     detail="Requires admin role",
                     detail="Requires admin role",

+ 388 - 0
backend/tests/integration/test_users_groups_privilege_escalation.py

@@ -0,0 +1,388 @@
+"""Privilege-escalation regression suite for the users/groups admin boundary.
+
+The intent declared in ``permissions.py`` is that USERS_* / GROUPS_* are
+admin-level capabilities — the comments literally say "(admin-level)".
+The original implementation enforced ONLY the permission, not admin role.
+Any user holding USERS_UPDATE (or USERS_CREATE / GROUPS_UPDATE /
+GROUPS_CREATE) could grant themselves admin via the management routes.
+
+This suite reproduces every attack vector from the disclosure and pins
+the fail-closed behaviour. Each negative test grants the operator the
+minimum permission needed to *reach* the route gate, then asserts the
+admin gate blocks them. A companion positive test verifies the same
+operation succeeds with an admin token (so the admin gate doesn't
+over-block real flows).
+
+Default-install operators do NOT have USERS_* / GROUPS_* (see
+``DEFAULT_GROUPS``), so default deployments were never vulnerable
+unless an admin had explicitly granted the permission to a custom
+group — but anyone in that position would expect the boundary the
+comments described.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.group import Group
+
+
+async def _setup_admin(async_client: AsyncClient, username: str = "secadmin") -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _create_operator_with_perms(
+    async_client: AsyncClient,
+    admin_token: str,
+    db_session,
+    *,
+    username: str,
+    permissions: list[str],
+) -> tuple[str, int]:
+    """Create a non-admin user, drop them in a custom group with exactly
+    the requested permissions, return (token, user_id).
+
+    The operator is intentionally NOT an admin and NOT in the Administrators
+    group — they hold ONLY the listed permission strings. Mirrors the exact
+    deployment shape the security engineer described: an operator gifted
+    one admin-level permission via a custom group ends up able to escalate
+    to full admin without the gate.
+    """
+    headers = {"Authorization": f"Bearer {admin_token}"}
+
+    # Create a custom group carrying just the requested permissions.
+    grp_resp = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": f"escalation_test_{username}", "permissions": permissions},
+    )
+    assert grp_resp.status_code == 201, grp_resp.text
+    gid = grp_resp.json()["id"]
+
+    # Create a regular (role="user") user.
+    user_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": username, "password": "OpPass1234!", "role": "user", "group_ids": [gid]},
+    )
+    assert user_resp.status_code == 201, user_resp.text
+    uid = user_resp.json()["id"]
+
+    # Confirm the operator is NOT admin in the response shape.
+    assert user_resp.json()["is_admin"] is False
+
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "OpPass1234!"},
+    )
+    assert login.status_code == 200
+    return login.json()["access_token"], uid
+
+
+async def _admin_group_id(db_session) -> int:
+    result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
+    return result.scalar_one().id
+
+
+# ---------------------------------------------------------------------------
+# 1. PATCH /users/{id} {role: "admin"} — USERS_UPDATE holder cannot
+# self-promote
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_update_holder_cannot_set_role_to_admin(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, op_id = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op1", permissions=["users:update"]
+    )
+
+    resp = await async_client.patch(
+        f"/api/v1/users/{op_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 403
+
+    # And the operator is not admin in the DB after the attempted patch.
+    from backend.app.models.user import User
+
+    result = await db_session.execute(select(User).where(User.id == op_id))
+    user = result.scalar_one()
+    assert user.role == "user"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_update_holder_cannot_target_other_user(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op2", permissions=["users:update"]
+    )
+    # Create a separate target user.
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    target = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "target", "password": "TargetPass1!", "role": "user"},
+    )
+    target_id = target.json()["id"]
+
+    # Operator attempts to elevate target to admin.
+    resp = await async_client.patch(
+        f"/api/v1/users/{target_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 2. POST /users/ {role: "admin"} — USERS_CREATE holder cannot create admin
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_create_holder_cannot_create_admin(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op3", permissions=["users:create"]
+    )
+
+    resp = await async_client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"username": "newadmin", "password": "NewAdmin1!", "role": "admin"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 3. PATCH /groups/{id} {permissions: [...]} — GROUPS_UPDATE holder cannot
+# rewrite a group to admin-equivalent
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_update_holder_cannot_rewrite_permissions(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op4", permissions=["groups:update"]
+    )
+
+    # Admin creates a target group; operator tries to grant it everything.
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": "innocent", "permissions": ["printers:read"]},
+    )
+    gid = create.json()["id"]
+
+    from backend.app.core.permissions import ALL_PERMISSIONS
+
+    resp = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"permissions": ALL_PERMISSIONS},
+    )
+    assert resp.status_code == 403
+
+    # And the group still has its original (narrow) permissions.
+    result = await db_session.execute(select(Group).where(Group.id == gid))
+    assert result.scalar_one().permissions == ["printers:read"]
+
+
+# ---------------------------------------------------------------------------
+# 4. POST /groups/ {permissions: [...]} — GROUPS_CREATE holder cannot create
+# an admin-equivalent group
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_create_holder_cannot_create_admin_equivalent(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op5", permissions=["groups:create"]
+    )
+    from backend.app.core.permissions import ALL_PERMISSIONS
+
+    resp = await async_client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"name": "shadowadmins", "permissions": ALL_PERMISSIONS},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 5. POST /groups/{admin_gid}/users/{my_id} — GROUPS_UPDATE holder cannot
+# self-add to Administrators
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_update_holder_cannot_self_add_to_administrators(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, op_id = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op6", permissions=["groups:update"]
+    )
+    admin_gid = await _admin_group_id(db_session)
+
+    resp = await async_client.post(
+        f"/api/v1/groups/{admin_gid}/users/{op_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 6. PATCH /groups/{system_gid} — even an admin must not be able to strip
+# the Administrators group's permissions (DoS guard).
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_admin_cannot_strip_administrators_group_permissions(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    admin_gid = await _admin_group_id(db_session)
+
+    resp = await async_client.patch(
+        f"/api/v1/groups/{admin_gid}",
+        headers=headers,
+        json={"permissions": []},
+    )
+    assert resp.status_code == 400
+    assert "system groups" in resp.json()["detail"].lower()
+
+    # Untouched in DB.
+    result = await db_session.execute(select(Group).where(Group.id == admin_gid))
+    grp = result.scalar_one()
+    assert len(grp.permissions or []) > 0
+
+
+# ---------------------------------------------------------------------------
+# Positive companions — admin should succeed on each route (the admin gate
+# must not over-block normal admin flows).
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_admin_can_still_perform_user_role_change(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    target = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "promoteme", "password": "Promote1!", "role": "user"},
+    )
+    tid = target.json()["id"]
+
+    resp = await async_client.patch(
+        f"/api/v1/users/{tid}",
+        headers=headers,
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 200
+    assert resp.json()["role"] == "admin"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_administrators_group_member_passes_admin_gate(async_client: AsyncClient, db_session):
+    """A user whose admin status comes from Administrators-group membership
+    rather than the legacy ``role`` column must pass the admin gate. The
+    canonical signal is ``User.is_admin``, not ``role == 'admin'``.
+
+    Uses a write endpoint (PATCH /users/{id} {role}) since the admin gate
+    lives on writes only — reads stay at ``USERS_READ`` so operator UIs
+    (Stats filter-by-user, Archives Print Log, File Manager username
+    autocomplete) keep working for non-admin operators who hold the
+    read permission via a custom group."""
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    admin_gid = await _admin_group_id(db_session)
+
+    # Create a regular user, then add them to Administrators.
+    user_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "groupadmin", "password": "GroupAdmin1!", "role": "user"},
+    )
+    uid = user_resp.json()["id"]
+    add = await async_client.post(f"/api/v1/groups/{admin_gid}/users/{uid}", headers=headers)
+    assert add.status_code == 204
+
+    # Also create a separate target user to mutate (cleaner than self-modify).
+    target_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "target_member", "password": "Target1234!", "role": "user"},
+    )
+    target_id = target_resp.json()["id"]
+
+    login = await async_client.post("/api/v1/auth/login", json={"username": "groupadmin", "password": "GroupAdmin1!"})
+    group_admin_token = login.json()["access_token"]
+
+    # Through an admin-gated write route — must succeed.
+    resp = await async_client.patch(
+        f"/api/v1/users/{target_id}",
+        headers={"Authorization": f"Bearer {group_admin_token}"},
+        json={"is_active": False},
+    )
+    assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
+    """Operator-visible UIs (Stats filter-by-user, Archives Print Log
+    username column, File Manager username autocomplete) reach
+    ``GET /users/`` for non-admin operators when a deployment granted
+    them ``users:read`` via a custom group. The admin gate must NOT
+    apply to read endpoints — only to writes."""
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="reader", permissions=["users:read"]
+    )
+
+    resp = await async_client.get("/api/v1/users/", headers={"Authorization": f"Bearer {op_token}"})
+    assert resp.status_code == 200
+    # Operator is in the list with is_admin=False — confirms the read is
+    # working AND the operator hasn't escalated.
+    me = next(u for u in resp.json() if u["username"] == "reader")
+    assert me["is_admin"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
+    """Companion to ``users:read``. ``GET /groups/`` + ``GET /groups/
+    permissions`` stay reachable to non-admin operators with the read
+    permission. Used by setup wizards / informational lookups."""
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="greader", permissions=["groups:read"]
+    )
+
+    headers = {"Authorization": f"Bearer {op_token}"}
+    list_resp = await async_client.get("/api/v1/groups/", headers=headers)
+    assert list_resp.status_code == 200
+    perms_resp = await async_client.get("/api/v1/groups/permissions", headers=headers)
+    assert perms_resp.status_code == 200