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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<h1 align="center">FrameX</h1>

<p align="center">
Build modular Python services with plug-and-play plugins, clear team boundaries, and transparent API integration.
🚀 Build scalable Python services with plugins — like FastAPI + Ray, but modular by design.
</p>

<p align="center">
Expand Down
70 changes: 70 additions & 0 deletions src/framex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class TestConfig(BaseModel):


class OauthConfig(BaseModel):
provider: str = ""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

provider defaults to empty string — verify downstream tolerance.

OauthConfig.provider defaults to "", which means oauth_callback will stamp an empty string into the session/JWT (oauth_provider). can_access_repository(..., auth_payload.get("oauth_provider"), ...) must handle empty/unknown providers gracefully (e.g., fall back to URL-based detection), otherwise misconfigured deployments silently fail the repo access check. Consider making provider required via Literal["github", "gitlab"] or validating it in model_post_init.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framex/config.py` at line 58, OauthConfig.provider currently defaults to
an empty string which ends up stored as oauth_provider and can cause
can_access_repository to silently fail; fix by making provider explicit or
validating it: change the OauthConfig.provider field to a stricter type (e.g.,
Literal["github","gitlab"]) or add a model_post_init on OauthConfig that asserts
provider is non-empty and either sets a detected provider (from redirect/URL) or
raises a clear validation error; alternatively, update oauth_callback to avoid
stamping an empty string into the session/JWT and ensure
can_access_repository(auth_payload.get("oauth_provider"), ...) has a fallback
path when provider is missing.

client_id: str = ""
client_secret: str = ""
authorization_url: str = ""
Expand Down Expand Up @@ -84,6 +85,73 @@ def model_post_init(self, context: Any) -> None:
self.jwt_secret = secrets.token_urlsafe(32)


class RepositoryProviderAuthConfig(BaseModel):
token: str = ""
token_header: str = "Authorization" # noqa
token_scheme: str = "Bearer" # noqa

def build_headers(self) -> dict[str, str]:
if not self.token:
return {}
if self.token_scheme:
return {self.token_header: f"{self.token_scheme} {self.token}"}
return {self.token_header: self.token}


class GitLabRepositoryAuthEndpointConfig(RepositoryProviderAuthConfig):
host: str
path_prefix: str = ""
token_header: str = "PRIVATE-TOKEN" # noqa
token_scheme: str = ""

def matches(self, host: str, path: str) -> bool:
normalized_prefix = self.normalized_path_prefix
if self.host.lower() != host.lower():
return False
if not normalized_prefix:
return True
return path == normalized_prefix or path.startswith(f"{normalized_prefix}/")

@property
def normalized_path_prefix(self) -> str:
if not self.path_prefix:
return ""
return self.path_prefix if self.path_prefix.startswith("/") else f"/{self.path_prefix}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class GitLabRepositoryAuthConfig(RepositoryProviderAuthConfig):
token_header: str = "PRIVATE-TOKEN" # noqa
token_scheme: str = ""
endpoints: list[GitLabRepositoryAuthEndpointConfig] = Field(default_factory=list)

def configured_hosts(self) -> set[str]:
return {endpoint.host.lower() for endpoint in self.endpoints}
Comment on lines +122 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Global GitLab auth cannot target a self-hosted instance by itself.

configured_hosts() only returns hosts from endpoints, and src/framex/repository/providers/gitlab.py only matches self-hosted GitLab URLs when the host appears there. If a deployment sets only the global repository.auth.gitlab.token, version/access checks for gitlab.internal.example never run.

One way to model the missing host mapping
 class GitLabRepositoryAuthConfig(RepositoryProviderAuthConfig):
+    hosts: list[str] = Field(default_factory=list)
     token_header: str = "PRIVATE-TOKEN"  # noqa
     token_scheme: str = ""
     endpoints: list[GitLabRepositoryAuthEndpointConfig] = Field(default_factory=list)

     def configured_hosts(self) -> set[str]:
-        return {endpoint.host.lower() for endpoint in self.endpoints}
+        return {host.lower() for host in self.hosts} | {endpoint.host.lower() for endpoint in self.endpoints}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framex/config.py` around lines 118 - 124, The
GitLabRepositoryAuthConfig.configured_hosts method only returns hosts from
endpoints so a global token (set on the provider config) never applies to
self-hosted GitLab instances; update configured_hosts in class
GitLabRepositoryAuthConfig to include a wildcard/fallback host (for example an
empty string or "*" entry) when the provider-level token is set (check the
inherited token field on RepositoryProviderAuthConfig) so that the matching
logic in src/framex/repository/providers/gitlab.py will consider the global
token for self-hosted hosts as well.


def build_headers_for_url(self, host: str, path: str) -> dict[str, str]:
if endpoint := self.resolve_endpoint(host, path):
return endpoint.build_headers()
return self.build_headers()

def resolve_endpoint(self, host: str, path: str) -> GitLabRepositoryAuthEndpointConfig | None:
matches = [endpoint for endpoint in self.endpoints if endpoint.matches(host, path)]
if not matches:
return None
return max(matches, key=lambda endpoint: len(endpoint.normalized_path_prefix))


class RepositoryAuthConfig(BaseModel):
github: RepositoryProviderAuthConfig = Field(default_factory=RepositoryProviderAuthConfig)
gitlab: GitLabRepositoryAuthConfig = Field(default_factory=GitLabRepositoryAuthConfig)


class RepositoryConfig(BaseModel):
auth: RepositoryAuthConfig = Field(default_factory=RepositoryAuthConfig)


class DocsConfig(BaseModel):
embedded_config_file_whitelist: list[str] = Field(default_factory=list)


class AuthConfig(BaseModel):
oauth: OauthConfig | None = Field(default=None)
rules: dict[str, list[str]] = Field(default_factory=dict)
Expand Down Expand Up @@ -131,8 +199,10 @@ class Settings(BaseSettings):
load_builtin_plugins: list[str] = Field(default_factory=list)

test: TestConfig = Field(default_factory=TestConfig)
docs: DocsConfig = Field(default_factory=DocsConfig)
sentry: SentryConfig = Field(default_factory=SentryConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)

model_config = SettingsConfigDict(
# `.env.prod` takes priority over `.env`
Expand Down
81 changes: 79 additions & 2 deletions src/framex/driver/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Callable
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any
from zoneinfo import ZoneInfo

Expand All @@ -23,8 +24,21 @@

from framex.config import settings
from framex.consts import API_PRE_STR, DOCS_URL, OPENAPI_URL, PROJECT_NAME, REDOC_URL, VERSION
from framex.driver.auth import authenticate, oauth_callback
from framex.utils import build_swagger_ui_html, format_uptime, safe_error_message
from framex.driver.auth import authenticate, get_auth_payload, oauth_callback
from framex.plugin import get_plugin
from framex.repository import (
can_access_repository,
get_latest_repository_version,
has_newer_release_version,
is_private_repository,
)
from framex.utils import (
build_plugin_config_html,
build_swagger_ui_html,
collect_embedded_config_files,
format_uptime,
safe_error_message,
)

FRAME_START_TIME = datetime.now(tz=UTC)
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
Expand Down Expand Up @@ -109,6 +123,69 @@ async def _on_start(deployment: Any) -> None:
async def get_documentation(_: Annotated[str, Depends(authenticate)]) -> HTMLResponse:
return build_swagger_ui_html(openapi_url=OPENAPI_URL, title="FrameX Docs")

@application.get("/docs/plugin-config", include_in_schema=False)
async def get_plugin_config_documentation(
request: Request,
plugin: str,
_: Annotated[str, Depends(authenticate)],
) -> HTMLResponse:
if not settings.auth.oauth:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Plugin config documentation requires auth"
)

loaded_plugin = get_plugin(plugin)
auth_payload = get_auth_payload(request)
repo_url = (
loaded_plugin.metadata.url if loaded_plugin is not None and loaded_plugin.metadata is not None else ""
)

if not repo_url or auth_payload is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Repository access denied: {plugin}")

repository_is_private = is_private_repository(repo_url)
if repository_is_private is not False:
access_result = can_access_repository(
repo_url,
auth_payload.get("oauth_provider"),
auth_payload.get("oauth_access_token"),
)
if access_result is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=f"Repository access denied: {plugin}"
)

loaded_config = loaded_plugin.config.model_dump() if loaded_plugin and loaded_plugin.config else None
config_data = loaded_config or settings.plugins.get(plugin) # type: ignore
if config_data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plugin config not found: {plugin}")

return build_plugin_config_html(
config_data,
collect_embedded_config_files(
config_data,
workspace_root=Path.cwd().resolve(),
whitelist=settings.docs.embedded_config_file_whitelist,
),
)

@application.get("/docs/plugin-release", include_in_schema=False)
async def get_plugin_release_documentation(
plugin: str,
_: Annotated[str, Depends(authenticate)],
) -> dict[str, Any]:
loaded_plugin = get_plugin(plugin)
if loaded_plugin is None or loaded_plugin.metadata is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plugin not found: {plugin}")

current_version = loaded_plugin.metadata.version
current_version = current_version if current_version.startswith("v") else f"v{current_version}"
repo_url = loaded_plugin.metadata.url
latest_version = get_latest_repository_version(repo_url)
if not latest_version or not has_newer_release_version(current_version, latest_version):
return {"has_update": False, "latest_version": None, "repo_url": repo_url}
return {"has_update": True, "latest_version": latest_version, "repo_url": repo_url}

@application.get(REDOC_URL, include_in_schema=False)
async def get_redoc_documentation(_: Annotated[str, Depends(authenticate)]) -> HTMLResponse:
return get_redoc_html(openapi_url=OPENAPI_URL, title="FrameX Redoc")
Expand Down
119 changes: 86 additions & 33 deletions src/framex/driver/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from datetime import UTC, datetime, timedelta
from secrets import token_urlsafe
from typing import Any

import httpx
import jwt
Expand All @@ -11,57 +13,97 @@
from framex.consts import AUTH_COOKIE_NAME, DOCS_URL

api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
SESSION_LIFETIME = timedelta(hours=24)
_AUTH_SESSIONS: dict[str, dict[str, Any]] = {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This session store only works on one process.

_AUTH_SESSIONS lives in module memory, so a login created on worker A is invisible to worker B, and every restart drops all active sessions. That will make docs auth flaky as soon as this runs with multiple workers/instances. Please move the session state to shared storage (for example Redis/DB), or keep the full auth state in the signed token if stateless auth is the goal.

Also applies to: 46-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framex/driver/auth.py` at line 17, The in-memory session store
_AUTH_SESSIONS makes auth process-local and volatile; replace it with a shared
persistent store or switch to stateless tokens: implement a session backend
using Redis/DB (e.g., wrap get/set/delete operations used by the auth functions
that reference _AUTH_SESSIONS) and update all places that read/write
_AUTH_SESSIONS to call that backend, or alternatively encode the complete auth
state into the signed token and remove reliance on _AUTH_SESSIONS; update the
functions/methods that currently interact with _AUTH_SESSIONS (and the related
code referenced around lines 46-55) to use the chosen shared/session-backend
API.



def create_jwt(payload: dict) -> str:
def _now_utc() -> datetime:
return datetime.now(UTC)


def _purge_expired_sessions(now_utc: datetime | None = None) -> None:
current = now_utc or _now_utc()
expired_session_ids = [
session_id for session_id, payload in _AUTH_SESSIONS.items() if payload.get("expires_at", current) <= current
]
for session_id in expired_session_ids:
_AUTH_SESSIONS.pop(session_id, None)


def create_jwt(payload: dict[str, Any]) -> str:
if not settings.auth.oauth:
raise RuntimeError("OAuth not configured")

now_utc = datetime.now(UTC)
now_utc = _now_utc()
token_payload = {
**payload,
"iat": int(now_utc.timestamp()),
"exp": int((now_utc + SESSION_LIFETIME).timestamp()),
}
return jwt.encode(token_payload, settings.auth.oauth.jwt_secret, algorithm=settings.auth.oauth.jwt_algorithm)

payload.update(
{
"iat": int(now_utc.timestamp()),
"exp": int((now_utc + timedelta(hours=24)).timestamp()),
}
)
return jwt.encode(payload, settings.auth.oauth.jwt_secret, algorithm=settings.auth.oauth.jwt_algorithm)

def create_auth_session(session_payload: dict[str, Any]) -> str:
now_utc = _now_utc()
expires_at = now_utc + SESSION_LIFETIME
session_id = token_urlsafe(32)
_AUTH_SESSIONS[session_id] = {
**session_payload,
"expires_at": expires_at,
}
_purge_expired_sessions(now_utc)
return session_id

def auth_jwt(request: Request) -> bool:
if not settings.auth.oauth:
return False

token = request.cookies.get(AUTH_COOKIE_NAME)
if not token:
return False
def decode_auth_token(token: str | None) -> dict[str, Any] | None:
if not settings.auth.oauth or not token:
return None

try:
jwt.decode(
payload = jwt.decode(
token,
settings.auth.oauth.jwt_secret,
algorithms=[settings.auth.oauth.jwt_algorithm],
)
return True
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
return False
return None

if not isinstance(payload, dict):
return None

def authenticate(request: Request, api_key: str | None = Depends(api_key_header)) -> None:
if settings.auth.oauth:
if token := request.cookies.get(AUTH_COOKIE_NAME):
try:
jwt.decode(
token,
settings.auth.oauth.jwt_secret,
algorithms=[settings.auth.oauth.jwt_algorithm],
)
return
session_id = payload.get("session_id")
if not isinstance(session_id, str) or not session_id:
return None

except Exception as e:
from framex.log import logger
now_utc = _now_utc()
_purge_expired_sessions(now_utc)
session_payload = _AUTH_SESSIONS.get(session_id)
if session_payload is None:
return None

logger.warning(f"JWT decode failed: {e}")
expires_at = session_payload.get("expires_at")
if not isinstance(expires_at, datetime) or expires_at <= now_utc:
_AUTH_SESSIONS.pop(session_id, None)
return None

return {
**payload,
**{key: value for key, value in session_payload.items() if key != "expires_at"},
}


def get_auth_payload(request: Request) -> dict[str, Any] | None:
return decode_auth_token(request.cookies.get(AUTH_COOKIE_NAME))


def auth_jwt(request: Request) -> bool:
return get_auth_payload(request) is not None


def authenticate(request: Request, api_key: str | None = Depends(api_key_header)) -> None:
if settings.auth.oauth:
if get_auth_payload(request) is not None:
return

if api_key and api_key in (settings.auth.get_auth_keys(request.url.path) or []):
return
Expand All @@ -74,7 +116,7 @@ def authenticate(request: Request, api_key: str | None = Depends(api_key_header)
f"?client_id={settings.auth.oauth.client_id}"
"&response_type=code"
f"&redirect_uri={settings.auth.oauth.call_back_url}"
"&scope=read_user"
"&scope=read_user%20read_api"
)
},
)
Expand Down Expand Up @@ -116,12 +158,23 @@ async def oauth_callback(code: str) -> Response:
"message": f"Welcome {username}",
"username": username,
"email": user_resp.get("email"),
"oauth_provider": settings.auth.oauth.provider,
"oauth_access_token": auth_token,
}
session_id = create_auth_session(user_info)

res = RedirectResponse(url=DOCS_URL, status_code=status.HTTP_302_FOUND)
res.set_cookie(
AUTH_COOKIE_NAME,
create_jwt(user_info),
create_jwt(
{
"message": user_info["message"],
"username": username,
"email": user_info["email"],
"oauth_provider": settings.auth.oauth.provider,
"session_id": session_id,
}
),
httponly=True,
samesite="lax",
)
Expand Down
1 change: 1 addition & 0 deletions src/framex/plugin/on.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def decorator(cls: type) -> type:
version,
plugin.module.__plugin_meta__.description,
plugin.module.__plugin_meta__.url,
plugin.name,
)

plugin_apis.append(
Expand Down
1 change: 1 addition & 0 deletions src/framex/plugins/proxy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ async def _parse_openai_docs(self, url: str) -> None:
f"v{__plugin_meta__.version}",
__plugin_meta__.description,
__plugin_meta__.url,
__plugin_meta__.name,
)
await adapter.call_func(
plugin_api,
Expand Down
Loading
Loading