Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ Write the date in place of the "Unreleased" in the case a new version is release
# Changelog


## v0.1.0-b37 (Unreleased)

### Changed

- Remove `SpecialUsers` principals for single-user and anonymous-access cases


## v0.1.0-b36 (2025-08-26)

### Changed

- Demoted the `Composite` structure family to `composite` spec.
- Typehint utils collection implementations


## v0.1.0-b35 (2025-08-20)

### Changed
Expand Down
8 changes: 4 additions & 4 deletions tiled/access_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .queries import AccessBlobFilter, In, KeysFilter
from .scopes import ALL_SCOPES, PUBLIC_SCOPES
from .utils import Sentinel, SpecialUsers, import_object
from .utils import Sentinel, import_object

ALL_ACCESS = Sentinel("ALL_ACCESS")
NO_ACCESS = Sentinel("NO_ACCESS")
Expand Down Expand Up @@ -79,7 +79,7 @@ def _get_id(self, principal):

async def allowed_scopes(self, node, principal, authn_scopes):
# If this is being called, filter_access has let us get this far.
if principal is SpecialUsers.public:
if principal is None:
allowed = PUBLIC_SCOPES
elif principal.type == "service":
allowed = self.scopes
Expand All @@ -95,7 +95,7 @@ async def allowed_scopes(self, node, principal, authn_scopes):
async def filters(self, node, principal, authn_scopes, scopes):
queries = []
query_filter = KeysFilter if not self.key else partial(In, self.key)
if principal is SpecialUsers.public:
if principal is None:
queries.append(query_filter(self.public))
else:
# Services have no identities; just use the uuid.
Expand All @@ -108,7 +108,7 @@ async def filters(self, node, principal, authn_scopes, scopes):
if not scopes.issubset(self.scopes):
return NO_ACCESS
access_list = self.access_lists.get(id, [])
if not ((principal is SpecialUsers.admin) or (access_list == self.ALL)):
if not (access_list == self.ALL):
try:
allowed = set(access_list or [])
except TypeError:
Expand Down
9 changes: 3 additions & 6 deletions tiled/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
default_deserialization_registry,
default_serialization_registry,
)
from ..utils import SHARE_TILED_PATH, Conflicts, SpecialUsers, UnsupportedQueryType
from ..utils import SHARE_TILED_PATH, Conflicts, UnsupportedQueryType
from ..validation_registration import ValidationRegistry, default_validation_registry
from .compression import CompressionMiddleware
from .router import get_router
Expand Down Expand Up @@ -360,10 +360,6 @@ async def would_delete_data_exception_handler(
async def unhandled_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
# The current_principal_logging_filter middleware will not have
# had a chance to finish running, so set the principal here.
principal = getattr(request.state, "principal", None)
current_principal.set(principal)
return await http_exception_handler(
request,
HTTPException(
Expand Down Expand Up @@ -855,7 +851,8 @@ async def capture_metrics_prometheus(
async def current_principal_logging_filter(
request: Request, call_next: RequestResponseEndpoint
):
request.state.principal = SpecialUsers.public
# Set a default, which may be overridden below by authentication code.
request.state.principal = None
response = await call_next(request)
current_principal.set(request.state.principal)
return response
Expand Down
55 changes: 22 additions & 33 deletions tiled/server/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import warnings
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional, Sequence, Union
from typing import Any, Optional, Sequence

from fastapi import (
APIRouter,
Expand Down Expand Up @@ -59,7 +59,7 @@
lookup_valid_pending_session_by_user_code,
lookup_valid_session,
)
from ..utils import SHARE_TILED_PATH, SpecialUsers
from ..utils import SHARE_TILED_PATH, SingleUserPrincipal
from . import schemas
from .core import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, json_or_msgpack
from .protocols import ExternalAuthenticator, InternalAuthenticator, UserSessionState
Expand Down Expand Up @@ -315,17 +315,18 @@ async def get_current_principal(
db: Optional[AsyncSession] = Depends(get_database_session),
# TODO: https://github.com/bluesky/tiled/issues/923
# Remove non-Principal return types
) -> Union[schemas.Principal, SpecialUsers]:
) -> Optional[schemas.Principal]:
"""
Get current Principal from:
- API key in 'api_key' query parameter
- API key in header 'Authorization: Apikey ...'
- API key in cookie 'tiled_api_key'
- OAuth2 JWT access token in header 'Authorization: Bearer ...'

Fall back to SpecialUsers.public, if anonymous access is allowed
If this server is configured with a "single-user API key", then
the Principal will be SpecialUsers.admin always.
If anonymous access is allowed, Principal will be `None`.
If the server is configured with a "single-user API key", then
the Principal will also be `None` - but is differentiated for
logging with a SingleUserPrincipal sentinel
"""

if api_key is not None:
Expand Down Expand Up @@ -371,9 +372,8 @@ async def get_current_principal(
)
else:
# Tiled is in a "single user" mode with only one API key.
if secrets.compare_digest(api_key, settings.single_user_api_key):
principal = SpecialUsers.admin
else:
principal = None
if not secrets.compare_digest(api_key, settings.single_user_api_key):
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
Expand All @@ -390,9 +390,12 @@ async def get_current_principal(
)
else:
# No form of authentication is present.
principal = SpecialUsers.public
principal = None
# This is used to pass the currently-authenticated principal into the logger.
request.state.principal = principal
is_apikey_single_user = api_key is not None and not request.app.state.authenticated
request.state.principal = (
principal if not is_apikey_single_user else SingleUserPrincipal
)
return principal


Expand Down Expand Up @@ -819,9 +822,7 @@ async def principal_list(
limit: Optional[int] = Query(
DEFAULT_PAGE_SIZE, alias="page[limit]", ge=0, le=MAX_PAGE_SIZE
),
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
_=Security(check_scopes, scopes=["read:principals"]),
db: Optional[AsyncSession] = Depends(get_database_session),
):
Expand Down Expand Up @@ -859,9 +860,7 @@ async def principal_list(
)
async def create_service_principal(
request: Request,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
_=Security(check_scopes, scopes=["write:principals"]),
db: Optional[AsyncSession] = Depends(get_database_session),
role: str = Query(...),
Expand Down Expand Up @@ -960,9 +959,7 @@ async def apikey_for_principal(
request: Request,
uuid: uuid_module.UUID,
apikey_params: schemas.APIKeyRequestParams,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
_=Security(check_scopes, scopes=["admin:apikeys"]),
db: Optional[AsyncSession] = Depends(get_database_session),
):
Expand Down Expand Up @@ -1013,9 +1010,7 @@ async def revoke_session(
async def revoke_session_by_id(
session_id: str, # from path parameter
request: Request,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
db: Optional[AsyncSession] = Depends(get_database_session),
):
"Mark a Session as revoked so it cannot be refreshed again."
Expand Down Expand Up @@ -1106,9 +1101,7 @@ async def slide_session(refresh_token, settings, db):
async def new_apikey(
request: Request,
apikey_params: schemas.APIKeyRequestParams,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
_=Security(check_scopes, scopes=["apikeys"]),
db: Optional[AsyncSession] = Depends(get_database_session),
):
Expand Down Expand Up @@ -1166,9 +1159,7 @@ async def current_apikey_info(
async def revoke_apikey(
request: Request,
first_eight: str,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
_=Security(check_scopes, scopes=["apikeys"]),
db: Optional[AsyncSession] = Depends(get_database_session),
):
Expand Down Expand Up @@ -1198,14 +1189,12 @@ async def revoke_apikey(
)
async def whoami(
request: Request,
principal: Union[schemas.Principal, SpecialUsers] = Depends(
get_current_principal
),
principal: Optional[schemas.Principal] = Depends(get_current_principal),
db: Optional[AsyncSession] = Depends(get_database_session),
):
# TODO Permit filtering the fields of the response.
request.state.endpoint = "auth"
if principal is SpecialUsers.public:
if principal is None:
return json_or_msgpack(request, None)
# The principal from get_current_principal tells us everything that the
# access_token carries around, but the database knows more than that.
Expand Down
5 changes: 2 additions & 3 deletions tiled/server/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional, Union
from typing import List, Optional

import pydantic_settings
from fastapi import HTTPException, Query, Request
Expand All @@ -7,7 +7,6 @@
from tiled.adapters.protocols import AnyAdapter
from tiled.server.schemas import Principal
from tiled.structures.core import StructureFamily
from tiled.utils import SpecialUsers

from ..type_aliases import Scopes
from ..utils import BrokenLink
Expand All @@ -22,7 +21,7 @@ def get_root_tree(request: Request):
async def get_entry(
path: str,
security_scopes: List[str],
principal: Union[Principal, SpecialUsers],
principal: Optional[Principal],
authn_scopes: Scopes,
root_tree: pydantic_settings.BaseSettings,
session_state: dict,
Expand Down
12 changes: 7 additions & 5 deletions tiled/server/principal_log_filter.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
from logging import Filter, LogRecord

from ..utils import SpecialUsers
from ..utils import UNSET, SingleUserPrincipal
from .app import current_principal


class PrincipalFilter(Filter):
"""Logging filter to attach username or Service Principal UUID to LogRecord"""

def filter(self, record: LogRecord) -> bool:
principal = current_principal.get(None)
if principal is None:
principal = current_principal.get(UNSET)
if principal is UNSET:
# This will only occur if an uncaught exception was raised in the
# server before the authentication code ran. This will always be
# associated with a 500 Internal Server Error response.
short_name = "unset"
elif isinstance(principal, SpecialUsers):
short_name = f"{principal.value}"
elif principal is None:
short_name = "anon"
elif principal is SingleUserPrincipal:
short_name = "singleuser"
elif principal.type == "service":
short_name = f"service:{principal.uuid}"
else: # principal.type == "user"
Expand Down
Loading
Loading