Let API clients resolve user ids to names (#1894)

Archives, the queue and statistics report ownership as a numeric
created_by_id, and statistics accept it as a filter, but nothing let an
API key discover whose id was whose -- the only user listing returns
emails, roles, group membership and full permission sets, so it is
administrative and rejects keys.

Add GET /users/slim returning id + username only, gated on a new
users:read_slim permission mapped to can_read_status. That grants no
data a key could not already reach: for API-keyed requests the
permission deps return None as current_user, so the stats:filter_by_user
guard short-circuits and ?created_by_id=N is already honoured for every
N. What was missing was the ability to address the filter, not
permission to use it. The full listing stays unmapped = admin-only.

Also fix /auth/me, which answered an API key with a synthetic
administrator: id 0, role admin, is_admin true and every permission in
the enum. A key cannot reach an administrative route at all, so clients
building their UI from that response rendered actions that 403 on use.
It now reports the key owner's identity, is_admin false, and the
permissions the key's scopes actually admit. Ownerless legacy keys keep
id 0 but no longer claim admin.

---

Source user names from the slim listing where only names are needed (#1894)

Stats filter-by-user, the Archives print log filter, the File Manager
username autocomplete, the camera-token owner column and the Finance
member picker all render nothing but a username, but all of them read
the full user listing, which is gated on the admin-level users:read.
An operator granted stats:filter_by_user but not users:read got an
empty filter with no indication why.

Point them at /users/slim under a separate react-query key, since the
full listing shares the 'users' key and the two shapes would clobber
each other in the cache.
This commit is contained in:
maziggy
2026-08-10 11:43:23 +02:00
parent 71709c16aa
commit 7c117dc6bc
22 changed files with 940 additions and 57 deletions
@@ -0,0 +1,240 @@
"""An API key must not out-rank the user it belongs to (#1894 follow-on).
``_check_apikey_permissions`` gated purely on the scope flags stored on the key
row and never looked at the owner. Scope flags are chosen at creation time by
whoever holds ``api_keys:create`` -- admin-only in the default groups, but a
custom group can grant it -- so a user with, say, queue permissions could mint
themselves a key with ``can_control_printer`` and stop other people's prints
through it. Deactivating that user did not help either: their keys kept working
with full scope authority, because nothing re-checked the owner.
The gate now narrows the scope flags to what the owner may do. Two cases must
NOT be conflated, and each has a test below:
- ``user_id IS NULL`` -- legacy key from before per-user ownership. No owner
exists to narrow against, so the flags stand alone and the key keeps working.
- ``user_id`` set but the row is gone or deactivated -- the key's authority came
from a user who has none. Fails closed.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.core.auth import generate_api_key, get_password_hash
from backend.app.models.api_key import APIKey
from backend.app.models.group import Group
from backend.app.models.user import User
# A route gated on PRINTERS_READ (can_read_status) and one gated on
# PRINTERS_CONTROL (can_control_printer). Both scope flags are set on every key
# built below, so any denial comes from the owner check rather than the flags.
READ_ROUTE = "/api/v1/printers/"
CONTROL_ROUTE = "/api/v1/printers/1/print/stop"
async def _setup(async_client: AsyncClient) -> None:
await async_client.post(
"/api/v1/auth/setup",
json={"auth_enabled": True, "admin_username": "owneradmin", "admin_password": "OwnerPass1!"},
)
async def _key_for(db_session, owner: User | None, **scopes) -> str:
defaults = {"can_read_status": True, "can_control_printer": True, "can_queue": True}
defaults.update(scopes)
full_key, key_hash, key_prefix = generate_api_key()
db_session.add(
APIKey(
name=f"key-{owner.username if owner else 'legacy'}",
key_hash=key_hash,
key_prefix=key_prefix,
enabled=True,
user_id=owner.id if owner else None,
**defaults,
)
)
await db_session.commit()
return full_key
async def _user(db_session, username: str, permissions: list[str], *, is_active: bool = True) -> User:
group = Group(name=f"grp-{username}", description="t", permissions=permissions, is_system=False)
db_session.add(group)
await db_session.flush()
user = User(
username=username,
password_hash=get_password_hash("Whatever1!"), # noqa: S106
role="user",
is_active=is_active,
groups=[group],
)
db_session.add(user)
await db_session.commit()
return user
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_owned_key_keeps_full_scope_authority(async_client: AsyncClient, db_session):
"""The common case must not regress -- almost every key is admin-owned."""
await _setup(async_client)
admin = (await db_session.execute(select(User).where(User.username == "owneradmin"))).scalar_one()
key = await _key_for(db_session, admin)
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_legacy_ownerless_key_still_works(async_client: AsyncClient, db_session):
"""No owner to narrow against is not the same as a failed owner lookup."""
await _setup(async_client)
key = await _key_for(db_session, None)
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_key_cannot_exceed_its_owners_permissions(async_client: AsyncClient, db_session):
"""The escalation: control flags ticked, owner who may not control."""
await _setup(async_client)
owner = await _user(db_session, "readonly", ["printers:read"])
key = await _key_for(db_session, owner)
allowed = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
denied = await async_client.post(CONTROL_ROUTE, headers={"X-API-Key": key})
assert allowed.status_code == 200
assert denied.status_code == 403
assert "owner does not have" in denied.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient, db_session):
"""Previously the key kept working -- nothing re-checked the owner."""
await _setup(async_client)
owner = await _user(db_session, "gone", ["printers:read"], is_active=False)
key = await _key_for(db_session, owner)
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
assert response.status_code == 403
assert "deactivated" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session):
"""The dangling-row case fails closed rather than reverting to flags-only.
CASCADE should prevent this, but "should" is not a gate -- if the row is
ever orphaned the key must not silently regain full scope authority.
"""
await _setup(async_client)
owner = await _user(db_session, "doomed", ["printers:read"])
key = await _key_for(db_session, owner)
api_key = (await db_session.execute(select(APIKey).where(APIKey.user_id == owner.id))).scalar_one()
api_key.user_id = 999999 # owner row that does not exist
await db_session.commit()
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bearer_path_is_gated_the_same_as_the_header(async_client: AsyncClient, db_session):
"""Both credential paths run the same gate; only one was ever tested."""
await _setup(async_client)
owner = await _user(db_session, "bearer-readonly", ["printers:read"])
key = await _key_for(db_session, owner)
denied = await async_client.post(CONTROL_ROUTE, headers={"Authorization": f"Bearer {key}"})
assert denied.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_webhook_routes_are_not_a_way_around_the_owner_check(async_client: AsyncClient, db_session):
"""/webhook/* reaches its scope flags by a different route than the rest.
It gates on ``check_permission``, not ``_check_apikey_permissions``, so it
does not inherit the owner narrowing for free. If it is missed, the same key
that is refused on /printers/{id}/print/stop simply stops the print here
instead, and the whole gate is decorative.
"""
await _setup(async_client)
owner = await _user(db_session, "webhook-readonly", ["printers:read"])
key = await _key_for(db_session, owner)
denied = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
assert denied.status_code == 403
assert "owner does not have" in denied.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_webhook_still_works_for_a_permitted_owner(async_client: AsyncClient, db_session):
"""The narrowing must not simply break every webhook caller."""
await _setup(async_client)
owner = await _user(db_session, "webhook-operator", ["printers:read", "printers:control"])
key = await _key_for(db_session, owner)
response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
# There is no connected printer 1, so the handler itself fails. What
# matters is that the request got that far: neither gate rejected it.
assert response.status_code != 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_webhook_rejects_a_deactivated_owner(async_client: AsyncClient, db_session):
"""Fail-closed applies on this path too."""
await _setup(async_client)
owner = await _user(db_session, "webhook-gone", ["printers:read", "printers:control"], is_active=False)
key = await _key_for(db_session, owner)
response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
assert response.status_code == 403
assert "deactivated" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_reports_the_narrowed_set(async_client: AsyncClient, db_session):
"""/auth/me and the gate must agree, including about the owner."""
await _setup(async_client)
owner = await _user(db_session, "narrow", ["printers:read"])
key = await _key_for(db_session, owner)
result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})).json()
assert result["id"] == owner.id
assert "printers:read" in result["permissions"]
# can_control_printer is ticked on the key, but the owner cannot control.
assert "printers:control" not in result["permissions"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_is_rejected_once_the_owner_is_deactivated(async_client: AsyncClient, db_session):
"""A dead key identifies as nothing, rather than as an anonymous key."""
await _setup(async_client)
owner = await _user(db_session, "me-gone", ["printers:read"], is_active=False)
key = await _key_for(db_session, owner)
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})
assert response.status_code == 403
+131 -7
View File
@@ -232,8 +232,8 @@ class TestAuthMeAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
"""Verify /me returns synthetic admin user when using API key via Bearer token."""
async def test_me_with_ownerless_api_key_bearer(self, async_client: AsyncClient, db_session):
"""A legacy key has no identity to report, but no longer claims admin (#1894)."""
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
@@ -253,15 +253,18 @@ class TestAuthMeAPI:
result = response.json()
assert result["id"] == 0
assert result["username"].startswith("api-key:")
assert result["role"] == "admin"
assert result["is_admin"] is True
assert result["role"] != "admin"
assert result["is_admin"] is False
assert result["is_active"] is True
# can_read_status defaults True, so the scope-derived set is non-empty
# -- but it is a set, not "every permission there is".
assert len(result["permissions"]) > 0
assert "users:create" not in result["permissions"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
"""Verify /me returns synthetic admin user when using X-API-Key header."""
async def test_me_with_ownerless_api_key_header(self, async_client: AsyncClient, db_session):
"""Same as above via the X-API-Key header rather than Bearer."""
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
@@ -279,7 +282,7 @@ class TestAuthMeAPI:
result = response.json()
assert result["id"] == 0
assert result["username"].startswith("api-key:")
assert result["is_admin"] is True
assert result["is_admin"] is False
@pytest.mark.asyncio
@pytest.mark.integration
@@ -292,6 +295,127 @@ class TestAuthMeAPI:
assert response.status_code == 401
async def _owned_key(self, async_client: AsyncClient, db_session, **scopes):
"""Set up auth and return (owner, full_key) for a key with ``scopes``.
The owner is given an email and a group explicitly rather than relying
on what /auth/setup happens to seed, so the assertions about what /me
withholds cannot pass vacuously.
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
from backend.app.models.group import Group
from backend.app.models.user import User
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "keyowner",
"admin_password": "KeyPass1!",
},
)
owner = (
await db_session.execute(select(User).where(User.username == "keyowner").options(selectinload(User.groups)))
).scalar_one()
owner.email = "keyowner@example.invalid"
group = Group(name="key-owner-group", description="t", permissions=["printers:read"], is_system=False)
db_session.add(group)
await db_session.flush()
owner.groups.append(group)
full_key, key_hash, key_prefix = generate_api_key()
db_session.add(
APIKey(name="owned", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=owner.id, **scopes)
)
await db_session.commit()
return owner, full_key
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_reports_the_key_owner_not_a_synthetic_admin(self, async_client: AsyncClient, db_session):
"""The id is the point of #1894 -- it is what created_by_id filters on."""
owner, full_key = await self._owned_key(async_client, db_session)
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
assert response.status_code == 200
result = response.json()
assert result["id"] == owner.id
assert result["username"] == "keyowner"
# The owner is an admin; the key still is not, because no key reaches
# an administrative route regardless of who owns it.
assert result["is_admin"] is False
assert result["role"] != "admin"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_withholds_owner_email_and_groups(self, async_client: AsyncClient, db_session):
"""Identity, not the owner's profile -- anyone holding the key sees this."""
owner, full_key = await self._owned_key(async_client, db_session)
assert owner.email is not None and owner.groups # the helper made both non-empty
result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()
assert result["email"] is None
assert result["groups"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_permissions_track_the_key_scopes_not_the_owner(self, async_client: AsyncClient, db_session):
"""A key owned by an admin still reports only what its flags allow."""
_, full_key = await self._owned_key(
async_client,
db_session,
can_read_status=True,
can_control_printer=False,
can_queue=False,
)
perms = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()["permissions"]
assert "printers:read" in perms # can_read_status
assert "printers:control" not in perms # can_control_printer is off
assert "queue:create" not in perms # can_queue is off
assert "users:create" not in perms # administrative: unmapped for keys
@pytest.mark.asyncio
@pytest.mark.integration
async def test_me_permissions_are_exactly_what_the_gate_admits(self, async_client: AsyncClient, db_session):
"""/me must not drift from _check_apikey_permissions.
The whole defect in #1894 was a /me response that described a different
credential than the one the gate enforces, so pin them to each other
rather than to a hand-written list that can rot. The owner is threaded
through both sides for the same reason -- the gate narrows to the
owner's permissions, so a check that skipped the owner would stop
catching drift the moment the owner is not an administrator.
"""
from fastapi import HTTPException
from sqlalchemy import select
from backend.app.core.auth import _check_apikey_permissions, resolve_apikey_owner
from backend.app.core.permissions import ALL_PERMISSIONS
from backend.app.models.api_key import APIKey
_, full_key = await self._owned_key(async_client, db_session, can_read_status=True, can_control_printer=False)
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
reported = set(response.json()["permissions"])
key = (await db_session.execute(select(APIKey).where(APIKey.name == "owned"))).scalar_one()
owner = await resolve_apikey_owner(db_session, key)
for perm in ALL_PERMISSIONS:
try:
_check_apikey_permissions(key, [perm], owner=owner)
except HTTPException:
assert perm not in reported, f"/me reports '{perm}' but the gate denies it"
else:
assert perm in reported, f"the gate admits '{perm}' but /me omits it"
class TestUsersAPI:
"""Integration tests for /api/v1/users/ endpoints."""
@@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.core.auth import generate_api_key
from backend.app.core.config import settings as app_settings
from backend.app.models.api_key import APIKey
from backend.app.models.group import Group
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.user import User
@@ -173,7 +174,14 @@ class TestWebhookQueueAddAttribution:
async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
printer, archive = printer_and_archive
owner = User(username="keyowner", password_hash="x", is_active=True)
# The owner needs queue:create in their own right: a key is capped by
# its owner's permissions (#1894), so a bare account with no groups
# cannot queue through a key however its scope flags are set. This test
# is about who the row is credited to, not about the gate.
group = Group(name="queue-writers", description="t", permissions=["queue:create"], is_system=False)
db_session.add(group)
await db_session.flush()
owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group])
db_session.add(owner)
await db_session.commit()
await db_session.refresh(owner)
@@ -0,0 +1,180 @@
"""GET /api/v1/users/slim -- the id -> username mapping for API clients (#1894).
An API key could already read global archive stats and filter them by
``created_by_id`` (for API-keyed requests the permission deps return None as
``current_user``, so the ``stats:filter_by_user`` guard short-circuits), but
had no way to discover which id belonged to whom: the full listing is gated on
``users:read``, which is unmapped in the API-key scope allowlist and therefore
administrative.
The slim listing closes that gap without handing keys the full user objects.
These tests pin both halves: that it answers for a key, and that it stays
narrow while the full listing stays admin-only.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
from backend.app.models.group import Group
from backend.app.models.user import User
async def _setup_and_login(async_client: AsyncClient) -> str:
await async_client.post(
"/api/v1/auth/setup",
json={"auth_enabled": True, "admin_username": "slimadmin", "admin_password": "SlimPass1!"},
)
login = await async_client.post(
"/api/v1/auth/login",
json={"username": "slimadmin", "password": "SlimPass1!"},
)
return login.json()["access_token"]
async def _add_key(db_session, *, user_id: int | None = None, **scopes) -> str:
full_key, key_hash, key_prefix = generate_api_key()
db_session.add(
APIKey(name="slim-test", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=user_id, **scopes)
)
await db_session.commit()
return full_key
async def _add_user(db_session, username: str, **kwargs) -> User:
from backend.app.core.auth import get_password_hash
user = User(
username=username,
password_hash=get_password_hash("Whatever1!"),
email=f"{username}@example.invalid",
role="user",
is_active=True,
**kwargs,
)
db_session.add(user)
await db_session.commit()
return user
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_returns_only_id_and_username(async_client: AsyncClient, db_session):
"""The response shape is the contract -- no emails, roles, or permissions."""
token = await _setup_and_login(async_client)
await _add_user(db_session, "bob")
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
rows = response.json()
assert rows, "expected at least the admin created by setup"
for row in rows:
assert set(row) == {"id", "username"}
assert "bob" in [row["username"] for row in rows]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_is_reachable_with_an_api_key(async_client: AsyncClient, db_session):
"""The point of the issue: a key can resolve the ids it already filters on."""
await _setup_and_login(async_client)
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
header = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
bearer = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {full_key}"})
assert header.status_code == 200
assert bearer.status_code == 200
assert {row["username"] for row in header.json()} == {"slimadmin"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_needs_can_read_status(async_client: AsyncClient, db_session):
"""A key without the read scope gets nothing, same as any other read route."""
await _setup_and_login(async_client)
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=False)
response = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.integration
async def test_full_listing_stays_admin_only_for_api_keys(async_client: AsyncClient, db_session):
"""Regression guard: widening the slim route must not widen the full one.
``users:read`` returns emails, group membership and the complete permission
set for every account. It has to stay unmapped in the scope allowlist.
"""
await _setup_and_login(async_client)
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
response = await async_client.get("/api/v1/users", headers={"X-API-Key": full_key})
assert response.status_code == 403
assert "administrative" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slim_is_not_parsed_as_a_user_id(async_client: AsyncClient, db_session):
"""Route ordering. Declared after /{user_id}, "slim" would 422 as an int."""
token = await _setup_and_login(async_client)
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
assert response.status_code != 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_group_with_only_users_read_still_reaches_slim(async_client: AsyncClient, db_session):
"""``users:read`` is strictly broader, so it must pass the any-of gate.
Without this, every existing custom group holding ``users:read`` would need
a permission backfill before the frontend could ever move to this route.
"""
await _setup_and_login(async_client)
group = Group(name="readers", description="t", permissions=["users:read"], is_system=False)
db_session.add(group)
await db_session.flush()
await _add_user(db_session, "reader", groups=[group])
login = await async_client.post(
"/api/v1/auth/login",
json={"username": "reader", "password": "Whatever1!"},
)
token = login.json()["access_token"]
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_group_with_only_slim_cannot_read_the_full_listing(async_client: AsyncClient, db_session):
"""The narrow grant has to actually be narrower for JWT users too."""
await _setup_and_login(async_client)
group = Group(name="slim-only", description="t", permissions=["users:read_slim"], is_system=False)
db_session.add(group)
await db_session.flush()
await _add_user(db_session, "slimonly", groups=[group])
login = await async_client.post(
"/api/v1/auth/login",
json={"username": "slimonly", "password": "Whatever1!"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
assert (await async_client.get("/api/v1/users/slim", headers=headers)).status_code == 200
assert (await async_client.get("/api/v1/users", headers=headers)).status_code == 403