feat(auth): ServicePrincipal + is_service_account discriminator (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""API key authentication backend (Stage 1 PR2).
|
||||||
|
|
||||||
|
Resolves an ``Authorization: Bearer dfk_...`` token into a
|
||||||
|
``ServicePrincipal`` + workspace + scopes, so ``AuthMiddleware`` can
|
||||||
|
stamp the same contextvars a cookie-authenticated human would set
|
||||||
|
(spec D1: user_id = SA.id).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServicePrincipal:
|
||||||
|
"""Non-human principal backing an API key. Satisfies the
|
||||||
|
``deerflow.runtime.user_context.CurrentUser`` protocol."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
is_service_account: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scopes(scopes: str) -> list[str]:
|
||||||
|
"""Parse a comma-separated scope string into a permission list.
|
||||||
|
|
||||||
|
``"threads:read, threads:write"`` -> ``["threads:read", "threads:write"]``.
|
||||||
|
Empty / whitespace-only segments are dropped.
|
||||||
|
"""
|
||||||
|
return [s.strip() for s in scopes.split(",") if s.strip()]
|
||||||
@@ -31,6 +31,11 @@ class User(BaseModel):
|
|||||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||||
|
|
||||||
|
# Headless API discriminator (Stage 1 PR2). Always False for human
|
||||||
|
# users; ServicePrincipal sets it True. Lets downstream code branch
|
||||||
|
# on principal kind without isinstance gymnastics.
|
||||||
|
is_service_account: bool = Field(default=False, description="True only for API-key service accounts, never for human users")
|
||||||
|
|
||||||
# Workspace linkage (Stage 0 PR4)
|
# Workspace linkage (Stage 0 PR4)
|
||||||
default_workspace_id: str | None = Field(
|
default_workspace_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|||||||
@@ -42,11 +42,17 @@ from typing import Final, Protocol, runtime_checkable
|
|||||||
class CurrentUser(Protocol):
|
class CurrentUser(Protocol):
|
||||||
"""Structural type for the current authenticated user.
|
"""Structural type for the current authenticated user.
|
||||||
|
|
||||||
Any object with an ``.id: str`` attribute satisfies this protocol.
|
Requires ``.id: str`` plus ``.is_service_account: bool`` — the latter
|
||||||
Concrete implementations live in ``app.gateway.auth.models.User``.
|
distinguishes a human (cookie/JWT) principal from a headless service
|
||||||
|
account (API key). Concrete implementations:
|
||||||
|
``app.gateway.auth.models.User`` (False) and
|
||||||
|
``app.gateway.auth.api_key_backend.ServicePrincipal`` (True).
|
||||||
|
Readers that may run before either is set should use
|
||||||
|
``getattr(user, "is_service_account", False)``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
|
is_service_account: bool
|
||||||
|
|
||||||
|
|
||||||
_current_user: Final[ContextVar[CurrentUser | None]] = ContextVar("deerflow_current_user", default=None)
|
_current_user: Final[ContextVar[CurrentUser | None]] = ContextVar("deerflow_current_user", default=None)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for the API key auth backend (Stage 1 PR2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.gateway.auth.api_key_backend import ServicePrincipal, parse_scopes
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_scopes_splits_and_strips():
|
||||||
|
assert parse_scopes("threads:read, threads:write") == ["threads:read", "threads:write"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_scopes_empty_string_is_empty_list():
|
||||||
|
assert parse_scopes("") == []
|
||||||
|
assert parse_scopes(" ") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_scopes_drops_empty_segments():
|
||||||
|
assert parse_scopes("threads:read,,runs:create,") == ["threads:read", "runs:create"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_principal_is_service_account_true_by_default():
|
||||||
|
p = ServicePrincipal(id="sa-1")
|
||||||
|
assert p.id == "sa-1"
|
||||||
|
assert p.is_service_account is True
|
||||||
Reference in New Issue
Block a user