File indexing completed on 2026-11-09 10:47:37
0001 """Devcloud authentication and monitor authorization for embedded TeamComms.
0002
0003 The public proxy supplies an opaque reference to a single authenticated request.
0004 Devcloud revalidates its underlying session or token on every introspection.
0005 The monitor retains the authoritative component permissions and database.
0006 """
0007
0008 import hashlib
0009 import hmac
0010 import logging
0011 from datetime import datetime, timezone
0012 from pathlib import Path
0013 from urllib.parse import urlsplit
0014
0015 import httpx
0016 from django.conf import settings
0017 from django.contrib.auth import get_user_model
0018 from starlette.responses import JSONResponse
0019 from teamcomms.service.access import AccessError
0020 from teamcomms.service.dispatch import database_call
0021 from teamcomms.service.embedded import HostAuthentication, HostIdentity
0022
0023 from .authority import get_authority, is_ops
0024
0025 logger = logging.getLogger(__name__)
0026 BACKEND_PREFIX = "/swf-monitor/teamcomms"
0027 PUBLIC_PREFIX = "/prod/teamcomms"
0028 TEAM_ID = "8873a572-a319-4a90-bfc9-76a9b76e30fb"
0029 READ_SCOPES = frozenset({"directory:read", "entries:read", "comms:read", "dialog:read", "inflight:read", "capcom:read"})
0030 WRITE_SCOPES = frozenset({"entries:write", "sessions:write", "comms:write", "dialog:write", "inflight:write", "capcom:write"})
0031
0032
0033 def one_header(scope, name):
0034 values = [value.decode("latin1") for key, value in scope.get("headers", [])
0035 if key.lower() == name]
0036 if len(values) != 1 or not values[0]:
0037 raise AccessError("TeamComms proxy authentication required", 401)
0038 return values[0]
0039
0040
0041 def component_permissions(username):
0042 account = get_user_model().objects.filter(username=username).only("is_active").first()
0043 if account is not None and not account.is_active:
0044 raise AccessError("Monitor account is inactive", 401)
0045 authority = get_authority(username)
0046 return READ_SCOPES | WRITE_SCOPES, "admin" if is_ops(authority) else "member"
0047
0048
0049 class RemoteAuthentication(HostAuthentication):
0050 def __init__(self):
0051 super().__init__(provider="swf-remote", team_id=TEAM_ID,
0052 resolve=self.resolve_request, revalidate=self.revalidate_request,
0053 check_csrf=self.check_request_csrf)
0054 self.client = httpx.AsyncClient(timeout=3.0, follow_redirects=False, trust_env=False)
0055
0056 async def authenticate(self, scope, body):
0057
0058
0059 scope["teamcomms.body_sha256"] = hashlib.sha256(body).hexdigest()
0060 return await super().authenticate(scope, body)
0061
0062 async def introspect(self, scope):
0063 reference = one_header(scope, b"x-teamcomms-auth-ref")
0064 if len(reference) > 256:
0065 raise AccessError("Invalid TeamComms authentication reference", 401)
0066 endpoint = settings.SWF_TEAMCOMMS_INTROSPECTION_URL
0067 secret_file = settings.SWF_TEAMCOMMS_SERVICE_TOKEN_FILE
0068 try:
0069 secret = Path(secret_file).read_text().strip() if secret_file else ""
0070 except OSError:
0071 raise AccessError("TeamComms service credential unavailable", 503) from None
0072 target = urlsplit(endpoint)
0073 if (target.scheme != "https" or not target.hostname or target.username
0074 or target.password or target.fragment or not secret):
0075 raise AccessError("TeamComms authentication is not configured", 503)
0076 try:
0077 response = await self.client.post(endpoint, json={"reference": reference},
0078 headers={"Authorization": f"Bearer {secret}"})
0079 except httpx.HTTPError as error:
0080 logger.warning("TeamComms introspection unavailable (%s)", type(error).__name__)
0081 raise AccessError("Authentication authority unavailable", 503) from None
0082 if response.status_code in (401, 403):
0083 raise AccessError("Authentication expired, revoked or denied", response.status_code)
0084 if response.status_code != 200:
0085 raise AccessError("Authentication authority unavailable", 503)
0086 try:
0087 data = response.json()
0088 expires = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
0089 if expires.tzinfo is None:
0090 raise ValueError("Expiry requires a timezone")
0091 if expires <= datetime.now(timezone.utc):
0092 raise AccessError("Authentication reference expired", 401)
0093 suffix = scope["path"].removeprefix(PUBLIC_PREFIX)
0094 if (data["method"] != scope["method"] or data["path"] != suffix
0095 or data["query_string"] != scope.get("query_string", b"").decode("latin1")
0096 or not hmac.compare_digest(data["body_sha256"], scope["teamcomms.body_sha256"])):
0097 raise AccessError("Authentication reference does not match request", 401)
0098 if data["auth_method"] not in ("session", "token"):
0099 raise ValueError("Unknown authentication method")
0100 if not isinstance(data["csrf_verified"], bool):
0101 raise ValueError("Invalid CSRF attestation")
0102 except (ValueError, TypeError, KeyError, AttributeError):
0103 raise AccessError("Invalid authentication authority response", 503) from None
0104 scope["teamcomms.attestation"] = data
0105 return data
0106
0107 async def resolve_request(self, scope):
0108 data = await self.introspect(scope)
0109 try:
0110 subject, username, name = data["subject"], data["username"], data["name"]
0111 if not all(isinstance(value, str) and value for value in (subject, username, name)):
0112 raise ValueError("Invalid identity")
0113 session_authenticated = data["auth_method"] == "session"
0114 scopes, role = await database_call(component_permissions, username)
0115 operator = None
0116 if data["kind"] == "ai":
0117 owner = data["operator"]
0118 if (session_authenticated or subject != "ai:" + owner["subject"]
0119 or owner["username"] != username or not owner["subject"].isdigit()):
0120 raise ValueError("Invalid AI/operator binding")
0121 operator = HostIdentity(subject=owner["subject"], name=owner["name"],
0122 scopes=scopes, role=role, session_authenticated=False)
0123 elif data["kind"] in ("program", "connector"):
0124 account_subject = data["account_subject"]
0125 if (session_authenticated or not isinstance(account_subject, str)
0126 or not account_subject.isascii() or not account_subject.isdecimal()
0127 or account_subject.startswith("0")
0128 or subject != data["kind"] + ":" + account_subject
0129 or data.get("operator") is not None):
0130 raise ValueError("Invalid service/account binding")
0131 elif data["kind"] != "human" or not subject.isdigit() or data.get("operator"):
0132 raise ValueError("Invalid human binding")
0133 identity = HostIdentity(subject=subject, name=name, kind=data["kind"],
0134 scopes=scopes, role=role,
0135 session_authenticated=session_authenticated, operator=operator)
0136 identity.validate()
0137 return identity
0138 except (ValueError, TypeError, KeyError, AttributeError):
0139 raise AccessError("Invalid authentication authority identity", 503) from None
0140
0141 async def revalidate_request(self, scope, original_identity):
0142 return await self.resolve_request(scope)
0143
0144 async def check_request_csrf(self, scope, body, identity):
0145 return scope["teamcomms.attestation"]["csrf_verified"] is True
0146
0147
0148 class TrustedProxy:
0149 """Accept the designated loopback hop and restore the public URL for TC."""
0150
0151 def __init__(self, app):
0152 self.app = app
0153
0154 async def __call__(self, scope, receive, send):
0155 if scope["type"] != "http":
0156 return await self.app(scope, receive, send)
0157 try:
0158 if (scope.get("client") or ("", 0))[0] not in ("127.0.0.1", "::1"):
0159 raise AccessError("TeamComms requires the authenticated devcloud proxy", 401)
0160 host = one_header(scope, b"x-forwarded-host")
0161 if host != settings.SWF_TEAMCOMMS_PUBLIC_HOST or one_header(scope, b"x-forwarded-proto") != "https":
0162 raise AccessError("Invalid TeamComms proxy origin", 401)
0163 one_header(scope, b"x-teamcomms-auth-ref")
0164 path = scope["path"]
0165 if not (path == BACKEND_PREFIX or path.startswith(BACKEND_PREFIX + "/")):
0166 raise AccessError("Invalid TeamComms proxy path", 404)
0167 except AccessError as error:
0168 return await JSONResponse({"error": str(error)}, status_code=error.status,
0169 headers={"Cache-Control": "no-store"})(scope, receive, send)
0170 scope = dict(scope)
0171 scope["path"] = PUBLIC_PREFIX + path[len(BACKEND_PREFIX):]
0172 raw = scope.get("raw_path", path.encode("utf8"))
0173 scope["raw_path"] = PUBLIC_PREFIX.encode() + raw[len(BACKEND_PREFIX):]
0174 scope["root_path"] = ""
0175 scope["scheme"] = "https"
0176 scope["server"] = (host, 443)
0177 scope["headers"] = [(key, value) for key, value in scope["headers"] if key.lower() != b"host"]
0178 scope["headers"].append((b"host", host.encode("ascii")))
0179 await self.app(scope, receive, send)