From ec4769a33fe70aefcc59d16805647da5d1e9397f Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 11:31:49 +0800 Subject: [PATCH] feat(auth): ServicePrincipal + is_service_account discriminator (Stage 1 PR2) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/gateway/auth/api_key_backend.py | 32 +++++++++++++++++++ backend/app/gateway/auth/models.py | 5 +++ .../harness/deerflow/runtime/user_context.py | 10 ++++-- backend/tests/test_api_key_backend.py | 24 ++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 backend/app/gateway/auth/api_key_backend.py create mode 100644 backend/tests/test_api_key_backend.py diff --git a/backend/app/gateway/auth/api_key_backend.py b/backend/app/gateway/auth/api_key_backend.py new file mode 100644 index 00000000..ee3381b0 --- /dev/null +++ b/backend/app/gateway/auth/api_key_backend.py @@ -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()] diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index 4a904fec..0077df75 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -31,6 +31,11 @@ class User(BaseModel): 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") + # 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) default_workspace_id: str | None = Field( default=None, diff --git a/backend/packages/harness/deerflow/runtime/user_context.py b/backend/packages/harness/deerflow/runtime/user_context.py index ffe4be69..02e586a4 100644 --- a/backend/packages/harness/deerflow/runtime/user_context.py +++ b/backend/packages/harness/deerflow/runtime/user_context.py @@ -42,11 +42,17 @@ from typing import Final, Protocol, runtime_checkable class CurrentUser(Protocol): """Structural type for the current authenticated user. - Any object with an ``.id: str`` attribute satisfies this protocol. - Concrete implementations live in ``app.gateway.auth.models.User``. + Requires ``.id: str`` plus ``.is_service_account: bool`` — the latter + 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 + is_service_account: bool _current_user: Final[ContextVar[CurrentUser | None]] = ContextVar("deerflow_current_user", default=None) diff --git a/backend/tests/test_api_key_backend.py b/backend/tests/test_api_key_backend.py new file mode 100644 index 00000000..d378e18f --- /dev/null +++ b/backend/tests/test_api_key_backend.py @@ -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