| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- """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
|