Skip to content
Open
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
13 changes: 12 additions & 1 deletion scripts/pack_sdk_wheel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ echo "[4/5] staging into ${ARTIFACTS_DIR#"${REPO_ROOT}/"}"
mkdir -p "${ARTIFACTS_DIR}"
# Only this package's wheels. `rm -f *.whl` here would delete the CLI wheel the
# manifest still advertises, and /cli/download would 404 for the next release.
rm -f "${ARTIFACTS_DIR}"/bisheng_sdk-*.whl
# Published SDK versions remain available to applications with pinned requirements.
rm -f "${ARTIFACTS_DIR}/${WHEEL_NAME}"
cp "${WHEEL}" "${ARTIFACTS_DIR}/${WHEEL_NAME}"

if command -v sha256sum >/dev/null 2>&1; then
Expand All @@ -123,6 +124,16 @@ manifest = {}
if path.is_file():
manifest = json.loads(path.read_text(encoding="utf-8"))

previous_sdk = manifest.get("sdk")
history = manifest.get("sdk_history", [])
if isinstance(previous_sdk, dict):
history = [previous_sdk, *history]
retained = {}
for item in history:
if isinstance(item, dict) and item.get("filename") != os.environ["SDK_FILENAME"]:
retained[item["filename"]] = item
manifest["sdk_history"] = list(retained.values())

manifest["sdk"] = {
"version": os.environ["SDK_VERSION"],
"min_compatible": os.environ["SDK_MIN_COMPATIBLE"],
Expand Down
5 changes: 5 additions & 0 deletions src/app-proxy/app_proxy/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ def extract_access_token(connection: HTTPConnection) -> str | None:
and ``path=/`` (K7). Bearer second, because the platform SPA keeps its token
in localStorage and an app embedded in it may call through with a header.
"""
slug = connection.path_params.get("slug", "")
if is_valid_slug(slug):
desktop_token = connection.cookies.get("bisheng_app_session_" + slug)
if desktop_token:
return desktop_token
token = connection.cookies.get(ACCESS_TOKEN_COOKIE)
if token:
return token
Expand Down
11 changes: 11 additions & 0 deletions src/app-proxy/app_proxy/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ async def authorize(
},
)

async def redeem_desktop(self, *, slug: str, ticket: str) -> dict:
response = await self._send("POST", "/api/v1/internal/app-proxy/desktop-session",
json={"slug": slug, "ticket": ticket})
if response.status_code != 200:
raise InternalRpcError(self.peer, "Desktop ticket rejected")
envelope = response.json()
payload = self._unwrap(envelope)
if not isinstance(payload, dict) or not str(payload.get("session", "")).startswith("bs-view-"):
raise InternalRpcError(self.peer, "Desktop ticket rejected")
return payload

async def authorize_preview(
self,
*,
Expand Down
47 changes: 47 additions & 0 deletions src/app-proxy/app_proxy/desktop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""One-shot Desktop launch into a first-party isolated hosted-app view."""

import re
from urllib.parse import parse_qs, urlsplit

from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response

from app_proxy.authz import is_valid_slug
from app_proxy.clients import InternalRpcError, get_backend_client
from app_proxy.config import get_config
from app_proxy.routing import entry_prefix_for


async def handle_desktop_launch(request: Request):
slug = request.path_params["slug"]
if not is_valid_slug(slug):
return JSONResponse({"error": "invalid_launch"}, status_code=400)
# Bound the streaming body as well as Content-Length; do not log tickets.
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 256:
return JSONResponse({"error": "invalid_launch"}, status_code=400)
try:
body = parse_qs(raw.decode("ascii"), strict_parsing=True)
ticket, = body["ticket"]
if set(body) != {"ticket"} or not re.fullmatch(r"[A-Za-z0-9_-]{43}", ticket):
raise ValueError()
except (ValueError, KeyError, UnicodeError):
return JSONResponse({"error": "invalid_launch"}, status_code=400)
try:
payload = await get_backend_client().redeem_desktop(slug=slug, ticket=ticket)
except InternalRpcError:
return JSONResponse({"error": "launch_expired"}, status_code=401)
target = entry_prefix_for(slug, get_config().entry_prefix) + "/"
headers = {"Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Vary": "Accept"}
# Desktop renews the scoped cookie without navigating or reloading business
# inputs. Keep the form-post redirect for existing browser clients.
response = (Response(status_code=204, headers=headers)
if request.headers.get("accept") == "application/json"
else RedirectResponse(target, status_code=303, headers=headers))
public = str(getattr(get_config(), "entry_base_url", "") or "")
response.set_cookie("bisheng_app_session_" + slug, payload["session"], max_age=3600,
httponly=True, secure=urlsplit(public).scheme == "https" or request.url.scheme == "https",
samesite="strict", path=target)
return response
2 changes: 1 addition & 1 deletion src/app-proxy/app_proxy/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def _filter_cookie(value: str, session_cookie_name: str) -> str:
if not pair:
continue
name = pair.split("=", 1)[0].strip()
if name == session_cookie_name:
if name == session_cookie_name or name.startswith("bisheng_app_session_"):
continue
kept.append(pair)
return "; ".join(kept)
Expand Down
2 changes: 2 additions & 0 deletions src/app-proxy/app_proxy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ async def healthz() -> dict:
application.add_route(
f"{prefix}/{PREVIEW_SEGMENT}/{{session}}/{{tail:path}}", handle_preview_entry, methods=ENTRY_METHODS
)
from app_proxy.desktop import handle_desktop_launch
application.add_route(f"{prefix}/{{slug}}/_desktop", handle_desktop_launch, methods=["POST"])
# Two routes, because ``{tail:path}`` does not match the empty string:
# ``/apps/foo`` and ``/apps/foo/x`` are both entry points (AC-25).
application.add_route(f"{prefix}/{{slug}}", handle_entry, methods=ENTRY_METHODS)
Expand Down
7 changes: 6 additions & 1 deletion src/app-proxy/app_proxy/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,13 @@ async def forward(
# The raw byte pairs are exactly what arrived, so they are exactly what
# leaves. ``raw_headers`` is assigned after construction because
# ``StreamingResponse(headers=...)`` only accepts a Mapping.
stream = response.aiter_raw()
if response.headers.get("content-type", "").split(";", 1)[0].strip() == "text/event-stream":
from app_proxy.sse import authorized_events

stream = authorized_events(response, request, verdict, slug=slug, request_id=request_id)
proxied = StreamingResponse(
response.aiter_raw(),
stream,
status_code=response.status_code,
background=BackgroundTask(response.aclose),
)
Expand Down
40 changes: 40 additions & 0 deletions src/app-proxy/app_proxy/sse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Bound live event streams by current permissions, including idle streams."""

import asyncio
import time

from app_proxy.authz import authorize, authorize_preview, extract_access_token
from app_proxy.config import get_config


async def authorized_events(response, request, verdict, *, slug, request_id):
iterator = response.aiter_raw().__aiter__()
pending = None
interval = max(1.0, float(get_config().ws_reauthorize_interval_seconds))
deadline = time.monotonic() + interval
try:
while True:
if pending is None:
pending = asyncio.create_task(anext(iterator))
done, _ = await asyncio.wait({pending}, timeout=max(0, deadline - time.monotonic()))
if time.monotonic() >= deadline:
token = extract_access_token(request)
if verdict.preview_session:
current = await authorize_preview(session=verdict.preview_session, access_token=token, request_id=request_id)
else:
current = await authorize(slug=slug, access_token=token, request_id=request_id)
if not current.allowed:
break
deadline = time.monotonic() + interval
if pending in done:
try:
chunk = pending.result()
except StopAsyncIteration:
break
pending = None
yield chunk
finally:
if pending is not None:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await response.aclose()
6 changes: 4 additions & 2 deletions src/app-proxy/tests/test_authz_and_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,12 @@ def test_missing_hmac_secret_fails_closed(self, logged_in, fake_backend):
def test_fail_closed_is_not_cached(self, logged_in, fake_backend):
"""Caching an outage would extend a 1s blip into a 3s one for everybody."""
fake_backend.status_code = 500
assert "暂时无法" in logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS).text
# Use the canonical URL so the recovered navigation does not add the
# separate, intentionally reauthorized trailing-slash redirect.
assert "暂时无法" in logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS).text

fake_backend.status_code = 200
recovered = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS)
recovered = logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS)
assert "暂时无法" not in recovered.text, "the blip must not linger for the rest of the TTL"
assert len(fake_backend.calls) == 2, "the failed attempt was re-asked, not cached"

Expand Down
75 changes: 75 additions & 0 deletions src/app-proxy/tests/test_desktop_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Desktop launch uses an app-bound cookie, never a platform session."""

from types import SimpleNamespace
from unittest.mock import AsyncMock

from starlette.requests import Request
from starlette.testclient import TestClient

from app_proxy import desktop
from app_proxy.authz import extract_access_token
from app_proxy.headers import strip_platform_headers
from app_proxy.main import create_app


def test_launch_posts_ticket_and_sets_scoped_cookie(monkeypatch):
peer = SimpleNamespace(redeem_desktop=AsyncMock(return_value={"session": "bs-view-" + "s" * 43}))
monkeypatch.setattr(desktop, "get_backend_client", lambda: peer)
monkeypatch.setattr(desktop, "get_config", lambda: SimpleNamespace(entry_prefix="/apps", entry_base_url="https://example.test"))
with TestClient(create_app(), base_url="https://example.test") as client:
response = client.post("/apps/demo/_desktop", data={"ticket": "t" * 43}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/apps/demo/"
cookie = response.headers["set-cookie"]
assert "bisheng_app_session_demo=bs-view-" in cookie
assert "HttpOnly" in cookie and "Secure" in cookie and "Path=/apps/demo/" in cookie
assert "access_token_cookie" not in cookie
peer.redeem_desktop.assert_awaited_once_with(slug="demo", ticket="t" * 43)


def test_bad_ticket_is_rejected_before_backend(monkeypatch):
peer = SimpleNamespace(redeem_desktop=AsyncMock())
monkeypatch.setattr(desktop, "get_backend_client", lambda: peer)
with TestClient(create_app()) as client:
for data in ({"ticket": "bad"}, {"ticket": "t" * 43, "extra": "x"}):
assert client.post("/apps/demo/_desktop", data=data).status_code == 400
peer.redeem_desktop.assert_not_called()


def test_native_exchange_sets_same_scoped_cookie_without_redirect(monkeypatch):
peer = SimpleNamespace(redeem_desktop=AsyncMock(return_value={"session": "bs-view-" + "s" * 43}))
monkeypatch.setattr(desktop, "get_backend_client", lambda: peer)
monkeypatch.setattr(desktop, "get_config", lambda: SimpleNamespace(entry_prefix="/apps", entry_base_url="https://example.test"))
with TestClient(create_app(), base_url="https://example.test") as client:
response = client.post("/apps/demo/_desktop", data={"ticket": "t" * 43},
headers={"Accept": "application/json"}, follow_redirects=False)
assert response.status_code == 204
assert "location" not in response.headers
assert response.content == b""
assert response.headers["cache-control"] == "no-store"
assert response.headers["vary"] == "Accept"
cookie = response.headers["set-cookie"]
assert "bisheng_app_session_demo=bs-view-" in cookie
assert "HttpOnly" in cookie and "Secure" in cookie and "Path=/apps/demo/" in cookie
peer.redeem_desktop.assert_awaited_once_with(slug="demo", ticket="t" * 43)


def test_native_exchange_does_not_turn_expired_ticket_into_success(monkeypatch):
from app_proxy.clients import InternalRpcError

peer = SimpleNamespace(redeem_desktop=AsyncMock(side_effect=InternalRpcError("backend", "expired")))
monkeypatch.setattr(desktop, "get_backend_client", lambda: peer)
with TestClient(create_app()) as client:
response = client.post("/apps/demo/_desktop", data={"ticket": "t" * 43}, headers={"Accept": "application/json"})
assert response.status_code == 401
assert "set-cookie" not in response.headers


def test_desktop_cookie_is_app_specific_and_never_reaches_container():
headers = [("cookie", "access_token_cookie=platform; bisheng_app_session_demo=bs-view-abc; theme=dark")]
request = Request({"type": "http", "path": "/apps/demo/", "path_params": {"slug": "demo"},
"headers": [(b"cookie", headers[0][1].encode())]})
assert extract_access_token(request) == "bs-view-abc"
assert strip_platform_headers(headers) == [("cookie", "theme=dark")]
request.scope["path_params"]["slug"] = "different"
assert extract_access_token(request) == "platform"
32 changes: 32 additions & 0 deletions src/app-proxy/tests/test_sse_revocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Revoking a visitor ends even an otherwise idle event stream."""

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
from starlette.requests import Request

from app_proxy import sse
from app_proxy.authz import Verdict


@pytest.mark.asyncio
async def test_idle_sse_rechecks_access_and_closes_upstream(monkeypatch):
closed = asyncio.Event()
class Upstream:
async def aiter_raw(self):
await asyncio.Event().wait()
yield b"unreachable"
async def aclose(self):
closed.set()
monkeypatch.setattr(sse, "get_config", lambda: SimpleNamespace(ws_reauthorize_interval_seconds=1))
check = AsyncMock(return_value=Verdict(decision="forbidden"))
monkeypatch.setattr(sse, "authorize", check)
request = Request({"type": "http", "path": "/apps/demo/api/events", "path_params": {"slug": "demo"},
"headers": [(b"cookie", b"bisheng_app_session_demo=bs-view-session")]})
stream = sse.authorized_events(Upstream(), request, Verdict(decision="allow"), slug="demo", request_id="request")
with pytest.raises(StopAsyncIteration):
await asyncio.wait_for(anext(stream), 3)
assert closed.is_set()
check.assert_awaited_once_with(slug="demo", access_token="bs-view-session", request_id="request")
16 changes: 16 additions & 0 deletions src/backend/bisheng/app_publish/domain/models/app_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,22 @@ async def alist_by_app(cls, session: AsyncSession, app_id: str, limit: int = 50)
result = await session.exec(statement)
return list(result.all())

@classmethod
async def astatuses_by_version(cls, session: AsyncSession, app_id: str, version_ids: list[str]) -> dict[str, str]:
"""Read the latest attempt outcome without changing immutable versions."""
if not version_ids:
return {}
statement = (
select(AppDeployment.version_id, AppDeployment.status)
.where(AppDeployment.app_id == app_id, col(AppDeployment.version_id).in_(version_ids))
.order_by(col(AppDeployment.create_time).desc(), col(AppDeployment.id).desc())
)
result = await session.exec(statement)
statuses: dict[str, str] = {}
for version_id, status in result.all():
statuses.setdefault(str(version_id), status)
return statuses

@classmethod
async def aadvance_stage(
cls,
Expand Down
29 changes: 29 additions & 0 deletions src/backend/bisheng/app_publish/domain/repositories/admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Serialize package admission across API processes using the application row."""

from contextlib import asynccontextmanager

from sqlmodel import select

from bisheng.app_publish.domain.models.app_deployment import AppDeploymentDao
from bisheng.core.database import get_async_db_session
from bisheng.database.models.app import App


class AdmissionTransaction:
def __init__(self, session, app):
self._session = session
self.exists = app is not None
self.current_version_id = app.current_version_id if app is not None else None

async def insert(self, deployment):
await AppDeploymentDao.acreate(self._session, deployment)


class AdmissionRepository:
@staticmethod
@asynccontextmanager
async def locked(app_id):
async with get_async_db_session() as session:
app = (await session.exec(select(App).where(App.id == app_id).with_for_update())).first()
yield AdmissionTransaction(session, app)
await session.commit()
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@


class AppSecretConfigRepository:
def __init__(self, session_factory=get_async_db_session):
self._factory = session_factory
def __init__(self, session_factory=None):
self._factory = session_factory or get_async_db_session

@asynccontextmanager
async def transaction(self):
Expand Down
4 changes: 3 additions & 1 deletion src/backend/bisheng/app_publish/domain/schemas/failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,7 @@ def failure_from_error(exc: BaseErrorCode, *, stage: str) -> dict[str, Any]:
"code": int(getattr(exc, "code", 0) or 0),
"message": str(getattr(exc, "message", "") or ""),
"details": details if isinstance(details, dict) else ({"value": details} if details is not None else {}),
"hints": [str(hint) for hint in hints] if isinstance(hints, (list, tuple)) else ([] if not hints else [str(hints)]),
"hints": [str(hint) for hint in hints]
if isinstance(hints, (list, tuple))
else ([] if not hints else [str(hints)]),
}
Loading
Loading