Files
hermes-agent/gateway/platforms/api_server_room_grants.py
T

320 lines
13 KiB
Python

"""RoomLink room-member grants and capability HTTP handlers."""
import time
import uuid
from typing import Any
try:
from aiohttp import web
except ImportError:
web = None # type: ignore[assignment]
class RoomGrantReauthorizationRequired(ValueError):
"""A validly signed room grant was revoked or superseded."""
def _json_error(_openai_error, message: str, *, status: int, **error_kwargs) -> "web.Response":
"""``web.json_response(_openai_error(message, **kw), status=...)`` with the injected envelope builder."""
return web.json_response(_openai_error(message, **error_kwargs), status=status)
def _require_unchanged_execution_policy(
claims: dict[str, Any],
execution_policy: dict[str, Any],
) -> None:
"""Keep renewal from silently granting a changed execution policy."""
if str(execution_policy.get("policy_digest") or "") != str(
claims.get("execution_policy_digest") or ""
):
raise RoomGrantReauthorizationRequired("room execution policy changed")
def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response":
reauth = isinstance(exc, RoomGrantReauthorizationRequired)
return _json_error(
_openai_error,
"Room authorization needs to be renewed." if reauth else "Room authorization is invalid or expired.",
err_type="gateway_auth_error",
code="room_reauthorization_required" if reauth else "invalid_room_grant",
status=403 if reauth else 401,
)
def _hard_expiry(claims: dict[str, Any]) -> float:
return float(claims.get("status_expires_at", claims["expires_at"]))
def _require_local_target(claims: dict[str, Any], profile: str, installation_id: str) -> None:
if claims["target_profile"] != profile or claims["target_install_id"] != installation_id:
raise ValueError("room grant target does not match this profile")
def _local_room_catalog(self, profile: str, installation_id: str) -> tuple[dict, dict]:
"""Return ``(execution_policy, catalog)`` for this gateway's *profile*."""
from gateway.hosted_room_peer import PROTOCOL_VERSION, catalog_mapping
from gateway.hosted_room_execution_policy import execution_policy_mapping
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
catalog = catalog_mapping(
installation_id=installation_id,
protocol_versions=(PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
return execution_policy, catalog
def _http_routes(self) -> list[tuple[str, str, Any]]:
return [
("POST", "/v1/room-members/invitations", self._handle_room_member_invitation),
("GET", "/v1/room-members/capabilities", self._handle_room_member_capabilities),
("POST", "/v1/room-members/grants/refresh", self._handle_room_member_grant_refresh),
("POST", "/v1/room-members/grants/revoke", self._handle_room_member_grant_revoke),
]
def _room_grant_token(request: "web.Request") -> str:
authorization = str(request.headers.get("Authorization") or "")
scheme, separator, token = authorization.partition(" ")
if not separator or scheme.lower() != "hermesroom":
return ""
return token.strip()
def _room_grant_secret(self) -> bytes:
from gateway.hosted_room_peer import gateway_room_grant_secret
return gateway_room_grant_secret()
def _room_grant_claims(self, request: "web.Request", *, permission: str) -> dict[str, Any]:
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
claims = decode_room_grant(self._room_grant_secret(), token, permission=permission)
from gateway import hosted_rooms
db_path = hosted_rooms.default_db_path()
if hosted_rooms.room_grant_is_revoked(db_path, claims=claims):
raise RoomGrantReauthorizationRequired("room grant is revoked")
if not hosted_rooms.peer_room_grant_is_current(db_path, claims=claims):
raise RoomGrantReauthorizationRequired("room grant is no longer current")
return claims
async def _handle_room_member_invitation(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Mint a short-lived room/profile grant for a trusted home gateway."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
body, error = await self._read_json_body(request)
if error:
return error
required = {"room_id", "home_install_id", "authority_gateway_id", "authority_epoch", "member_id"}
allowed = required | {"grant_id", "ttl_seconds", "status_ttl_seconds"}
if set(body) - allowed or not required <= set(body):
return _json_error(
_openai_error, "Invitation is missing required room authority fields.",
code="invalid_room_invitation", status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import decode_room_grant, issue_room_grant
profile = _api_request_profile.get() or "default"
target_install_id = hosted_rooms.local_authority_gateway_id()
ttl = float(body.get("ttl_seconds", 3600))
if not 60 <= ttl <= 24 * 60 * 60:
raise ValueError("ttl_seconds must be between 60 and 86400")
status_ttl = float(body.get("status_ttl_seconds", ttl))
if not ttl <= status_ttl <= 30 * 24 * 60 * 60:
raise ValueError(
"status_ttl_seconds must be at least ttl_seconds and no more than 2592000"
)
execution_policy, catalog = _local_room_catalog(self, profile, target_install_id)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=str(body.get("grant_id") or f"grant-{uuid.uuid4().hex}"),
room_id=str(body["room_id"]),
home_install_id=str(body["home_install_id"]),
authority_gateway_id=str(body["authority_gateway_id"]),
authority_epoch=int(body["authority_epoch"]),
member_id=str(body["member_id"]),
target_install_id=target_install_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
issued_at=time.time(),
ttl_seconds=ttl,
status_ttl_seconds=status_ttl,
)
claims = decode_room_grant(self._room_grant_secret(), token, permission="status")
hosted_rooms.reserve_peer_room(
hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims)
)
except Exception as exc:
return _json_error(_openai_error, str(exc), code="invalid_room_invitation", status=400)
return web.json_response(
{
"object": "hermes.room_member.invitation",
"grant": token,
"target_profile": profile,
"catalog": catalog,
"expires_at": float(claims["expires_at"]),
"status_expires_at": float(claims["status_expires_at"]),
},
status=201,
)
async def _handle_room_member_capabilities(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Verify a scoped grant and return this target's live room catalog."""
try:
from gateway import hosted_rooms
claims = self._room_grant_claims(request, permission="status")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
_require_local_target(claims, profile, installation_id)
_, catalog = _local_room_catalog(self, profile, installation_id)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.capabilities",
"room_id": claims["room_id"],
"home_install_id": claims["home_install_id"],
"authority_gateway_id": claims["authority_gateway_id"],
"authority_epoch": claims["authority_epoch"],
"member_id": claims["member_id"],
"target_profile": profile,
"catalog": catalog,
}
)
async def _handle_room_member_grant_refresh(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Refresh dispatch access without a Desktop or broad gateway key."""
body, error = await self._read_json_body(request)
if error:
return error
if set(body) - {"ttl_seconds"}:
return _json_error(
_openai_error, "Grant refresh accepts only ttl_seconds.",
code="invalid_room_grant_refresh", status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import MAX_DISPATCH_GRANT_TTL_SECONDS, issue_room_grant
from gateway.hosted_room_execution_policy import execution_policy_mapping
# A status-only bearer may observe a run but must never mint new
# dispatch authority: renewal requires a still-live dispatch permission.
claims = self._room_grant_claims(request, permission="dispatch")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
_require_local_target(claims, profile, installation_id)
now = time.time()
hard_expiry = _hard_expiry(claims)
remaining = hard_expiry - now
requested = float(body.get("ttl_seconds", MAX_DISPATCH_GRANT_TTL_SECONDS))
if remaining <= 0 or requested <= 0:
raise ValueError("room grant renewal horizon expired")
dispatch_ttl = min(requested, MAX_DISPATCH_GRANT_TTL_SECONDS, remaining)
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
_require_unchanged_execution_policy(claims, execution_policy)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=f"grant-refresh-{uuid.uuid4().hex}",
room_id=claims["room_id"],
home_install_id=claims["home_install_id"],
authority_gateway_id=claims["authority_gateway_id"],
authority_epoch=int(claims["authority_epoch"]),
member_id=claims["member_id"],
target_install_id=installation_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
permissions=claims["permissions"],
issued_at=now,
ttl_seconds=dispatch_ttl,
status_expires_at=hard_expiry,
)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.grant",
"grant": token,
"expires_at": now + dispatch_ttl,
"status_expires_at": hard_expiry,
"execution_policy": execution_policy,
}
)
async def _handle_room_member_grant_revoke(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Revoke exactly the scoped grant authenticating this request."""
body, error = await self._read_json_body(request)
if error:
return error
if body:
return _json_error(
_openai_error, "Grant revoke accepts no fields.",
code="invalid_room_grant_revoke", status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
# Revoke is idempotent: a response-lost retry may authenticate with the
# grant just denylisted, so verify signature/scope/horizon directly
# (not via _room_grant_claims) and upsert the same grant id.
claims = decode_room_grant(self._room_grant_secret(), token, permission="status")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
_require_local_target(claims, profile, installation_id)
hosted_rooms.revoke_room_grant_scope(
hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims)
)
except Exception:
return _json_error(
_openai_error, "Room authorization is invalid or expired.",
err_type="gateway_auth_error", code="invalid_room_grant", status=401,
)
return web.json_response({"object": "hermes.room_member.grant.revocation", "revoked": True})