Skip to content

Commit 61584b9

Browse files
committed
fix(provisioning,api): surface handler failures + always-on loopback-admin middleware
aws_provisioning_adapter._provision_via_handlers now checks result["success"] before returning. Provider handlers that swallow exceptions and return {"success": False, "error_message": ...} were flowing through unchecked: instance_operation_service wrapped the failure in ProviderResult.success_result, the orchestrator saw success=True with resource_ids=[], and the request landed on FAILED with the misleading "No instances provisioned and no cloud resources created" message instead of the real error. Re-raising as InfrastructureError lets _handle_provisioning_failure stamp the actual cause. api.server now installs _LoopbackAdminTokenMiddleware unconditionally and calls _load_loopback_token outside the auth-enabled branch. The loopback-admin token written by the daemon at start (<work_dir>/server/orb-server.token) is the security model for intra-host admin operations; it must work even when the primary auth middleware is disabled, so the CLI reload command and the live REST tests can authenticate as admin via Authorization: Bearer <token>. tests/providers/aws/live/test_rest_api_onaws ORBServerManager reads the same token after the /health probe succeeds and threads it through to RestApiClient.session.headers as Authorization: Bearer <token>. Role-guarded routes (POST /machines/request, /machines/return, admin endpoints) now accept the live REST tests.
1 parent c4efa33 commit 61584b9

3 files changed

Lines changed: 100 additions & 3 deletions

File tree

src/orb/api/server.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,41 @@ def _load_loopback_token(server_config: Any) -> None:
117117
_server_logger.debug("loopback-admin token load skipped: %s", exc)
118118

119119

120+
class _LoopbackAdminTokenMiddleware:
121+
"""Always-on middleware that stamps admin identity for valid loopback tokens.
122+
123+
Runs regardless of whether the primary auth middleware is enabled. When
124+
the request carries ``Authorization: Bearer <token>`` and that token
125+
matches the value the daemon wrote to ``<work_dir>/server/orb-server.token``
126+
at startup, the middleware stamps ``request.state`` with the admin role so
127+
role-guarded routes (POST /machines/request, /admin/*, etc.) accept the
128+
call.
129+
130+
Fall-through (no header, or non-matching token) leaves request state
131+
untouched so the ``AuthMiddleware`` (when present) and ``get_current_user``
132+
dependency continue to behave as before.
133+
"""
134+
135+
def __init__(self, app: Any) -> None:
136+
from starlette.middleware.base import BaseHTTPMiddleware
137+
138+
self._impl = BaseHTTPMiddleware(app, dispatch=self._dispatch)
139+
140+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
141+
await self._impl(scope, receive, send)
142+
143+
@staticmethod
144+
async def _dispatch(request: Any, call_next: Any) -> Any:
145+
auth = request.headers.get("authorization", "")
146+
if auth.startswith("Bearer "):
147+
candidate = auth[7:].strip()
148+
if candidate and candidate in _LOOPBACK_ADMIN_TOKENS:
149+
request.state.user_id = "loopback-admin"
150+
request.state.user_roles = ["admin"]
151+
request.state.permissions = ["*"]
152+
return await call_next(request)
153+
154+
120155
def create_fastapi_app(server_config: Any) -> Any:
121156
"""
122157
Create and configure FastAPI application.
@@ -225,13 +260,21 @@ def create_fastapi_app(server_config: Any) -> Any:
225260
app.add_middleware(LoggingMiddleware)
226261
logger.info("Logging middleware enabled")
227262

263+
# Load the daemon-issued loopback-admin token unconditionally so the CLI's
264+
# reload command and the live REST tests can authenticate as admin
265+
# regardless of whether the primary auth middleware is enabled. The
266+
# token-only middleware below validates Authorization headers and stamps
267+
# request.state with the admin role; if no token (or a non-matching token)
268+
# is present, request.state is left untouched and the rest of the pipeline
269+
# behaves as before.
270+
_load_loopback_token(server_config)
271+
app.add_middleware(_LoopbackAdminTokenMiddleware)
272+
logger.info("Loopback-admin token middleware enabled")
273+
228274
# Add authentication middleware if enabled
229275
if server_config.auth.enabled:
230276
auth_strategy = _create_auth_strategy(server_config.auth)
231277
if auth_strategy:
232-
# Load the daemon-issued loopback-admin token (if present) so the
233-
# CLI reload command can authenticate without a user-facing JWT.
234-
_load_loopback_token(server_config)
235278
# Wrap the real strategy so loopback tokens are checked first.
236279
auth_port: Any = _LoopbackAdminAuthWrapper(auth_strategy)
237280
app.add_middleware(

src/orb/providers/aws/infrastructure/adapters/aws_provisioning_adapter.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ def _provision_via_handlers(
178178
# Acquire hosts using the handler — raises typed exception on failure
179179
result: dict[str, Any] = handler.acquire_hosts(request, aws_template) # type: ignore[arg-type]
180180

181+
# Some handler paths swallow exceptions and return a failure dict
182+
# (``{"success": False, "error_message": ...}``) instead of raising.
183+
# Without this guard, the failure flows through unchecked and
184+
# ``instance_operation_service`` wraps it in ``ProviderResult.success_result``,
185+
# so the orchestrator sees ``success=True`` with ``resource_ids=[]`` and the
186+
# request lands on ``FAILED`` with the misleading "No instances provisioned
187+
# and no cloud resources created" message. Re-raise here so the real error
188+
# makes it to ``_handle_provisioning_failure``.
189+
if not result.get("success", True):
190+
raise InfrastructureError(
191+
result.get("error_message", "Provider handler returned failure")
192+
)
193+
181194
resource_ids = result.get("resource_ids", [])
182195
self._logger.info("Successfully provisioned resources with IDs %s", resource_ids)
183196
return result

tests/providers/aws/live/test_rest_api_onaws.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ def __init__(
183183
self.log_path = log_path
184184
self._log_file_handle = None
185185
self._captured_log_path: Optional[str] = None
186+
# Loopback-admin token written by the daemon on start; the REST client
187+
# reads it and sends `Authorization: Bearer <token>` so the role-guarded
188+
# routes (POST /machines/request etc.) accept the request.
189+
self.loopback_admin_token: Optional[str] = None
186190

187191
def start(self, timeout: int | None = None):
188192
"""Start ORB server: orb server start --foreground --api-only --host <h> --port <p>"""
@@ -237,6 +241,7 @@ def start(self, timeout: int | None = None):
237241
)
238242
if response.status_code == 200:
239243
log.info("ORB server started successfully on %s", self.base_url)
244+
self.loopback_admin_token = self._read_loopback_token()
240245
return
241246
except requests.exceptions.RequestException:
242247
time.sleep(scenarios_rest_api.REST_API_SERVER["start_probe_interval"])
@@ -253,6 +258,34 @@ def start(self, timeout: int | None = None):
253258
)
254259
raise RuntimeError(error_msg)
255260

261+
def _read_loopback_token(self) -> Optional[str]:
262+
"""Read the loopback-admin bearer token written by ``orb server start``.
263+
264+
The daemon writes the token next to the pid file at
265+
``<work_dir>/server/orb-server.token`` (mode 0o600). The REST client
266+
sends it as ``Authorization: Bearer <token>`` so role-guarded routes
267+
(POST /machines/request, /machines/return, /requests, ...) authenticate
268+
as admin. Without the token the routes return 403 because the
269+
anonymous fallback resolves to the ``viewer`` role.
270+
271+
Returns ``None`` when the token file is missing — falls through to the
272+
unauthenticated client (older daemons or daemons started without
273+
``_write_token_file`` permissions).
274+
"""
275+
try:
276+
from orb.config.platform_dirs import get_work_location
277+
from orb.interface.server_daemon import _token_path
278+
279+
pid_path = get_work_location() / "server" / "orb-server.pid"
280+
token_file = _token_path(pid_path)
281+
if not token_file.exists():
282+
log.warning("Loopback admin token file not found at %s", token_file)
283+
return None
284+
return token_file.read_text(encoding="ascii").strip() or None
285+
except Exception as exc:
286+
log.warning("Failed to read loopback admin token: %s", exc)
287+
return None
288+
256289
def _read_captured_output(self) -> str:
257290
"""Read the last 2KB of the server log for error context."""
258291
try:
@@ -299,12 +332,18 @@ def __init__(
299332
timeout: int | None = None,
300333
api_prefix: str = scenarios_rest_api.REST_API_PREFIX,
301334
retry_attempts: int | None = None,
335+
auth_token: str | None = None,
302336
):
303337
self.base_url = base_url.rstrip("/")
304338
self.api_prefix = api_prefix
305339
self.timeout = timeout or REST_TIMEOUTS["rest_api_timeout"]
306340
self.retry_attempts = retry_attempts or REST_TIMEOUTS["rest_api_retry_attempts"]
307341
self.session = requests.Session()
342+
if auth_token:
343+
# The token is the loopback-admin bearer secret written by the
344+
# daemon at start; required for role-guarded routes when auth
345+
# is enabled OR when auth is disabled (anonymous → viewer).
346+
self.session.headers["Authorization"] = f"Bearer {auth_token}"
308347

309348
def _url(self, path: str) -> str:
310349
"""Construct full URL with API prefix."""
@@ -483,6 +522,7 @@ def rest_api_client(orb_server):
483522
api_prefix="/api/v1",
484523
timeout=REST_TIMEOUTS["rest_api_timeout"],
485524
retry_attempts=REST_TIMEOUTS["rest_api_retry_attempts"],
525+
auth_token=orb_server.loopback_admin_token,
486526
)
487527

488528

@@ -2239,6 +2279,7 @@ def test_rest_api_unknown_template_returns_error(setup_rest_api_environment):
22392279
base_url=server.base_url,
22402280
api_prefix="/api/v1",
22412281
timeout=REST_TIMEOUTS["rest_api_timeout"],
2282+
auth_token=server.loopback_admin_token,
22422283
)
22432284
try:
22442285
client.request_machines("NonExistent-Template-XYZ", 1)

0 commit comments

Comments
 (0)