Files
LegacyHUB/app/integrations/identity.py
Vadim Malanov d27dd0ffbb feat: align LegacyHUB with TeamHUB platform contract (D2/D4, assets, security)
Close 12 audit-driven platform-compliance gaps on a single branch.

- D4 dispatch: app/integrations/dispatch_client.py participant `legacyhub`,
  emits LegacyhubDocumentIndexed + AssetDerivativeReady after the indexing
  commit (idempotent uuid5), http_inbox route (reindex/tombstone) with
  audit-based dedupe; docs/dispatch-contract.md. Celery+Redis stays intra-module.
- D2 SSO: app/integrations/identity.py validates X-TeamHub-* + role/scope
  mapper; security.py adds trusted-header enforcement (AUTH_REQUIRE_IDENTITY)
  and a scope check on /search; docker-compose.teamhub.yml (external teamhub_net
  + internal db net, api not host-published); RUNBOOK network/firewall section.
- Asset standard: SearchHit/Citation carry asset_id/owner_module; buckets
  renamed teamhub-legacyhub-* (+quarantine/tmp/exports); purge-by-asset_id with
  legal-hold guard (app/indexing/projection.py); OCR-markdown derivative event.
- audit_log model + Alembic 0003 + record_audit on writes (same transaction).
- Secret masking: app/common/json_logger.py recursive mask wired into structlog
  (+ensure_ascii=False); event payloads redacted before persistence.
- Service X-API-Key mandatory on ingest endpoints (defence-in-depth).
- Port: host API 8000->8050 (collision with SalesHUB/MailHUB resolved),
  container still listens on 8000.
- Config: no plaintext secret defaults; fail-loud in non-dev (no value leak).
- Docs drift: README PG 5440, layered-auth note, 5173 removed from CORS;
  ingest/folder gated by ENABLE_FOLDER_INGEST (410 by default).
- ADRs: layers mapping, shared-core extraction, UI locale (RU-first).

Tests: 78 passing (ruff, compileall, pytest, tsc, vite build, compose config).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:44:15 +03:00

164 lines
5.5 KiB
Python

"""TeamHUB gateway trusted-identity adapter (D2 / 09_ROLLOUT_AND_SSO, 12_AUTH).
The module never implements its own login/IdP and never talks to AD/LDAP,
HRHUB or the ``staff`` database directly. User identity arrives as trusted
``X-TeamHub-*`` headers injected by the platform gateway/SSO. This adapter is
the single place those headers are parsed and mapped to local permissions.
Trust in these headers depends on network isolation: the module must be
reachable only behind the gateway (see ``docker-compose.teamhub.yml`` and the
RUNBOOK firewall note). The machine-to-machine ``X-API-Key`` layer
(``app/api/security.py``) is intentionally separate and must not be conflated
with this user-context layer.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import HTTPException, Request, status
# Exact trusted-header names injected by the gateway (09 §2.1, 12 Prompt 4).
HEADER_ACTOR = "X-TeamHub-Actor"
HEADER_ACTOR_ID = "X-TeamHub-Actor-Id"
HEADER_APP = "X-TeamHub-App"
HEADER_ROLE = "X-TeamHub-Role" # primary/default role, kept for ToolsHUB compat
HEADER_ROLES = "X-TeamHub-Roles"
HEADER_SCOPES = "X-TeamHub-Scopes"
HEADER_ENTITLEMENTS_VERSION = "X-TeamHub-Entitlements-Version"
# Domain scopes LegacyHUB authorizes against.
SCOPE_DOCUMENTS_READ = "documents:read"
SCOPE_DOCUMENTS_INGEST = "documents:ingest"
SCOPE_DOCUMENTS_ADMIN = "documents:admin"
# Local role -> scope mapping (app-specific Roles/Scopes per D2). Gateway is
# expected to inject only grants relevant to this app_code; this map is the
# module's interpretation of role names it receives.
ROLE_SCOPE_MAP: dict[str, frozenset[str]] = {
"admin": frozenset({SCOPE_DOCUMENTS_READ, SCOPE_DOCUMENTS_INGEST, SCOPE_DOCUMENTS_ADMIN}),
"ingestor": frozenset({SCOPE_DOCUMENTS_READ, SCOPE_DOCUMENTS_INGEST}),
"qa": frozenset({SCOPE_DOCUMENTS_READ}),
"viewer": frozenset({SCOPE_DOCUMENTS_READ}),
}
@dataclass(frozen=True)
class TeamHubIdentity:
actor: str
actor_id: str | None
app: str | None
primary_role: str | None
roles: tuple[str, ...]
scopes: tuple[str, ...]
entitlements_version: str | None
def has_role(self, role: str) -> bool:
return role in self.roles
def resolved_scopes(self) -> set[str]:
"""Union of explicit header scopes and scopes implied by roles."""
out: set[str] = set(self.scopes)
for role in self.roles:
out |= ROLE_SCOPE_MAP.get(role.lower(), frozenset())
return out
def has_scope(self, scope: str) -> bool:
return scope in self.resolved_scopes()
def _split_csv(value: str | None) -> tuple[str, ...]:
if not value:
return ()
return tuple(part.strip() for part in value.split(",") if part.strip())
def parse_identity(headers: Mapping[str, str]) -> TeamHubIdentity | None:
"""Parse trusted headers into a :class:`TeamHubIdentity`.
Returns ``None`` when no actor header is present (anonymous / direct dev
access). ``headers`` may be a Starlette ``Headers`` object (case-insensitive)
or any mapping keyed by the exact header names.
"""
actor = headers.get(HEADER_ACTOR)
if not actor or not actor.strip():
return None
primary_role = headers.get(HEADER_ROLE)
primary_role = primary_role.strip() if primary_role and primary_role.strip() else None
roles = _split_csv(headers.get(HEADER_ROLES))
if primary_role and primary_role not in roles:
roles = (primary_role, *roles)
return TeamHubIdentity(
actor=actor.strip(),
actor_id=(headers.get(HEADER_ACTOR_ID) or None),
app=(headers.get(HEADER_APP) or None),
primary_role=primary_role,
roles=roles,
scopes=_split_csv(headers.get(HEADER_SCOPES)),
entitlements_version=(headers.get(HEADER_ENTITLEMENTS_VERSION) or None),
)
# ---- FastAPI dependencies ----
def get_identity(request: Request) -> TeamHubIdentity | None:
"""Optional identity: parsed from the request, ``None`` if absent."""
return parse_identity(request.headers)
def require_identity(request: Request) -> TeamHubIdentity:
identity = get_identity(request)
if identity is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing trusted identity headers",
)
return identity
def require_scope(scope: str):
"""Dependency factory: require a resolved domain scope.
No-op when ``AUTH_REQUIRE_IDENTITY`` is off (direct local dev, no gateway):
identity is not present so scope cannot be enforced. Behind the gateway the
enforcement middleware guarantees an actor, and this checks the scope.
"""
def _dependency(request: Request) -> TeamHubIdentity | None:
from app.config import settings # noqa: PLC0415 - read current value
if not settings.auth_require_identity:
return None
identity = require_identity(request)
if not identity.has_scope(scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {scope}",
)
return identity
return _dependency
__all__ = [
"HEADER_ACTOR",
"HEADER_ACTOR_ID",
"HEADER_APP",
"HEADER_ENTITLEMENTS_VERSION",
"HEADER_ROLE",
"HEADER_ROLES",
"HEADER_SCOPES",
"SCOPE_DOCUMENTS_ADMIN",
"SCOPE_DOCUMENTS_INGEST",
"SCOPE_DOCUMENTS_READ",
"TeamHubIdentity",
"get_identity",
"parse_identity",
"require_identity",
"require_scope",
]