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
+44 -12
View File
@@ -21,6 +21,7 @@ from backend.app.core.auth import (
RequirePermissionIfAuthEnabled,
_is_token_fresh,
_validate_api_key,
apikey_effective_permissions,
authenticate_user,
authenticate_user_by_email,
create_access_token,
@@ -30,13 +31,13 @@ from backend.app.core.auth import (
get_user_by_email,
get_user_by_username,
is_jti_revoked,
resolve_apikey_owner,
resolve_session_max_minutes,
revoke_jti,
security,
)
from backend.app.core.database import async_session, get_db
from backend.app.core.oidc_env import env_bool
from backend.app.core.permissions import ALL_PERMISSIONS
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
from backend.app.models.group import Group
from backend.app.models.settings import Settings
@@ -89,17 +90,47 @@ def _user_to_response(user: User) -> UserResponse:
)
def _api_key_to_user_response(api_key) -> UserResponse:
"""Create a synthetic admin UserResponse for a valid API key."""
async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse:
"""Describe a valid API key as the identity it actually carries (#1894).
Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``,
``is_admin=True`` and every permission in the enum. That was wrong in both
directions. A key cannot perform administrative operations at all --
``_check_apikey_permissions`` denies every permission that is not in the
scope allowlist -- so a client that builds its UI from this response (which
is exactly what a native client does) rendered admin actions that 403 on
use, and had no way to learn the id its own prints are filed under.
Now: identity comes from the key's owner, and ``permissions`` is the set the
key can genuinely exercise. ``is_admin`` is always False because no key can
reach an administrative route regardless of who owns it.
Legacy keys predating per-user ownership (``user_id IS NULL``) have no
identity to report, so they keep ``id=0`` and the ``api-key:`` username --
but they stop claiming admin. ``created_at`` describes the credential in
both branches, unchanged.
"""
# Same resolution the permission gate uses, so what is reported here and
# what is enforced there cannot drift -- including the 403 when the owner
# has been deactivated, which makes the key dead rather than anonymous.
owner = await resolve_apikey_owner(db, api_key)
return UserResponse(
id=0,
username=f"api-key:{api_key.key_prefix}",
id=owner.id if owner else 0,
username=owner.username if owner else f"api-key:{api_key.key_prefix}",
# Withheld on purpose: the owner's email is not needed to resolve
# identity, and this response is reachable by anyone holding the key.
email=None,
role="admin",
# Deprecated free-text field; "user" is the existing value meaning
# "not an admin". Inventing an "api_key" role here would put a third
# value into a field callers compare against string literals.
role="user",
is_active=True,
is_admin=True,
is_admin=False,
auth_source=getattr(owner, "auth_source", "local") if owner else "local",
# The key is not a group member -- listing the owner's groups would
# imply capabilities the key does not inherit.
groups=[],
permissions=sorted(ALL_PERMISSIONS),
permissions=apikey_effective_permissions(api_key, owner),
created_at=api_key.created_at.isoformat(),
)
@@ -637,8 +668,9 @@ async def get_current_user_info(
"""Get current user information.
Accepts JWT tokens (via Authorization: Bearer header) and API keys
(via X-API-Key header or Authorization: Bearer bb_xxx).
API keys return a synthetic admin user with all permissions.
(via X-API-Key header or Authorization: Bearer bb_xxx). API keys report
their owner's identity and the permissions the key can actually exercise
-- see ``_api_key_to_user_response``.
"""
import jwt
from jwt.exceptions import PyJWTError as JWTError
@@ -647,7 +679,7 @@ async def get_current_user_info(
if x_api_key:
api_key = await _validate_api_key(db, x_api_key)
if api_key:
return _api_key_to_user_response(api_key)
return await _api_key_to_user_response(db, api_key)
# Check for Bearer token (could be JWT or API key)
if credentials is not None:
@@ -656,7 +688,7 @@ async def get_current_user_info(
if token.startswith("bb_"):
api_key = await _validate_api_key(db, token)
if api_key:
return _api_key_to_user_response(api_key)
return await _api_key_to_user_response(db, api_key)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
+11
View File
@@ -28,8 +28,19 @@ from backend.app.schemas.group import (
router = APIRouter(prefix="/groups", tags=["groups"])
# Permissions whose derived label would misdescribe what is being granted.
# The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a
# property of the users rather than of the response -- and an admin ticking a
# box in the group editor has nothing else to go on (#1894).
_PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
Permission.USERS_READ_SLIM: "List User Names (id + username only)",
}
def _permission_label(perm: Permission) -> str:
"""Convert permission enum to human-readable label."""
if perm in _PERMISSION_LABEL_OVERRIDES:
return _PERMISSION_LABEL_OVERRIDES[perm]
# e.g., "printers:read" -> "Read Printers"
parts = perm.value.split(":")
if len(parts) == 2:
+34 -1
View File
@@ -13,6 +13,7 @@ from backend.app.core.auth import (
ALGORITHM,
SECRET_KEY,
RequireAdminIfAuthEnabled,
RequireAnyPermissionIfAuthEnabled,
RequirePermissionIfAuthEnabled,
get_current_user_optional,
get_password_hash,
@@ -34,7 +35,14 @@ from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.models.user_otp_code import UserOTPCode
from backend.app.models.user_totp import UserTOTP
from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
from backend.app.schemas.auth import (
ChangePasswordRequest,
GroupBrief,
UserCreate,
UserResponse,
UserSlim,
UserUpdate,
)
from backend.app.services.email_service import (
create_welcome_email_from_template,
generate_secure_password,
@@ -190,6 +198,31 @@ async def create_user(
return _user_to_response(new_user)
@router.get("/slim", response_model=list[UserSlim])
async def list_users_slim(
_: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
db: AsyncSession = Depends(get_db),
):
"""List users as ``{id, username}`` only (#1894).
Exists so an API key -- or a group that should not see emails, roles and
permission sets -- can turn the ``created_by_id`` values it already gets
back from archives, stats and the queue into names.
``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
strictly broader; groups that already hold it keep working without a
permission backfill. For API keys only the slim permission resolves (the
full one is unmapped = administrative), so a key reaches this and not the
listing above.
Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
order, and the reverse order would parse "slim" as the int path parameter
and answer 422.
"""
result = await db.execute(select(User.id, User.username).order_by(User.username))
return [UserSlim(id=row.id, username=row.username) for row in result.all()]
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
+9 -7
View File
@@ -5,7 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import check_permission, check_printer_access, get_api_key
from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
from backend.app.core.database import get_db
from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
@@ -68,7 +68,7 @@ async def webhook_add_to_queue(
Requires 'can_queue' permission.
"""
check_permission(api_key, "queue")
await check_webhook_permission(db, api_key, "queue")
check_printer_access(api_key, data.printer_id)
# Verify archive exists
@@ -153,7 +153,7 @@ async def webhook_start_print(
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
await check_webhook_permission(db, api_key, "control_printer")
check_printer_access(api_key, printer_id)
# Get printer
@@ -191,12 +191,13 @@ async def webhook_start_print(
async def webhook_stop_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Stop the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
await check_webhook_permission(db, api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
@@ -222,12 +223,13 @@ async def webhook_stop_print(
async def webhook_cancel_print(
printer_id: int,
api_key: APIKey = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
):
"""Cancel the current print on a printer.
Requires 'can_control_printer' permission.
"""
check_permission(api_key, "control_printer")
await check_webhook_permission(db, api_key, "control_printer")
check_printer_access(api_key, printer_id)
status = printer_manager.get_status(printer_id)
@@ -257,7 +259,7 @@ async def webhook_get_printer_status(
Requires 'can_read_status' permission.
"""
check_permission(api_key, "read_status")
await check_webhook_permission(db, api_key, "read_status")
check_printer_access(api_key, printer_id)
# Get printer
@@ -293,7 +295,7 @@ async def webhook_get_queue_status(
Requires 'can_read_status' permission.
"""
check_permission(api_key, "read_status")
await check_webhook_permission(db, api_key, "read_status")
# Get printers
if printer_id: