mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
181 lines
7.0 KiB
Python
181 lines
7.0 KiB
Python
"""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
|