mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user