mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +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:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user