From 9c50afc585158127eb98154221b45ea270faf68e Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 13:57:38 +0530 Subject: [PATCH 1/4] feat: add global connection fallback registry --- src/authsome/server/credential_repository.py | 8 + src/authsome/server/credential_service.py | 430 +++++++++-- src/authsome/server/proxy_catalog.py | 65 +- src/authsome/server/routes/_deps.py | 1 + src/authsome/server/routes/connections.py | 49 ++ src/authsome/server/schemas.py | 25 + src/authsome/server/store/database.py | 23 + src/authsome/server/store/repositories.py | 87 ++- tests/auth/test_browser_service.py | 3 +- tests/auth/test_service.py | 714 +++++++++++++++++- tests/auth/test_service_provider_clients.py | 8 +- .../auth/test_service_provider_definitions.py | 2 + tests/proxy/test_proxy.py | 1 + tests/server/test_connection_details.py | 18 + tests/server/test_global_connections.py | 222 ++++++ tests/server/test_store_global_connections.py | 278 +++++++ 16 files changed, 1826 insertions(+), 108 deletions(-) create mode 100644 tests/server/test_global_connections.py create mode 100644 tests/server/test_store_global_connections.py diff --git a/src/authsome/server/credential_repository.py b/src/authsome/server/credential_repository.py index ad8aa7e3..9d0eb582 100644 --- a/src/authsome/server/credential_repository.py +++ b/src/authsome/server/credential_repository.py @@ -115,6 +115,14 @@ def vault(self) -> Vault: def vault_id(self) -> str: return self._vault_id + @property + def identity(self) -> str | None: + return self._identity + + @property + def principal_id(self) -> str | None: + return self._principal_id + @property def collection(self) -> str: return f"vault:{self._vault_id}" diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 734508dd..8a62667d 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -5,7 +5,8 @@ """ import json -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any, Self from urllib.parse import urlparse @@ -47,6 +48,8 @@ from authsome.server.config import get_server_config from authsome.server.credential_repository import CredentialRepository, parse_store_key from authsome.server.provider_repository import ProviderRepository +from authsome.server.schemas import GlobalConnectionSummaryResponse, GlobalProviderConnectionRecord +from authsome.server.store.repositories import GlobalProviderConnectionRegistry from authsome.utils import format_duration, utc_now from authsome.vault import Vault @@ -59,6 +62,13 @@ } +@dataclass(slots=True) +class EffectiveConnection: + record: ConnectionRecord + credentials: CredentialRepository + source: str + + class CredentialService: """ Authentication and credential lifecycle service. @@ -71,6 +81,7 @@ def __init__( # noqa: PLR0913 *, credentials: CredentialRepository, providers: ProviderRepository, + global_connections: GlobalProviderConnectionRegistry, identity: str | None = None, principal_id: str | None = None, principal_role: PrincipalRole = PrincipalRole.USER, @@ -82,6 +93,7 @@ def __init__( # noqa: PLR0913 self._vault_id = vault_id or credentials.vault_id self._principal_role = principal_role self._providers = providers + self._global_connections = global_connections @property def vault(self) -> Vault: @@ -105,6 +117,10 @@ def principal_id(self) -> str | None: def principal_role(self) -> PrincipalRole: return self._principal_role + @property + def global_connections(self) -> GlobalProviderConnectionRegistry: + return self._global_connections + @property def vault_id(self) -> str | None: return self._vault_id @@ -126,14 +142,19 @@ async def is_custom_provider(self, provider: str) -> bool: async def resolve_credentials(self, *, provider: str, connection: str | None = None) -> dict[str, Any]: """Resolve credentials for a provider/connection pair.""" - resolved_connection = await self.resolve_connection_name(provider, connection) - record = await self.get_connection(provider, resolved_connection) - headers = await self.get_auth_headers(provider, resolved_connection) + effective = await self.resolve_effective_connection(provider, connection) + definition = await self.get_provider(provider) + headers = await self._get_auth_headers_from_record_with_credentials( + effective.record, + definition, + effective.credentials, + ) return { "provider": provider, - "connection": resolved_connection, + "connection": effective.record.connection_name, "headers": headers, - "expires_at": record.expires_at.isoformat() if record.expires_at else None, + "expires_at": effective.record.expires_at.isoformat() if effective.record.expires_at else None, + "source": effective.source, } async def register_provider(self, definition: ProviderDefinition, *, force: bool = False) -> None: @@ -241,6 +262,38 @@ async def list_connection_records(self, provider: str) -> list[ConnectionRecord] records.append(record) return records + async def list_global_connection_summaries(self) -> list[GlobalConnectionSummaryResponse]: + """Return redacted summaries for deployment-wide global connection fallbacks.""" + summaries: list[GlobalConnectionSummaryResponse] = [] + for pointer in await self._global_connections.list_all(): + credentials = self._credentials_for_vault( + pointer.owner_vault_id, + identity=pointer.created_by_identity, + principal_id=pointer.owner_principal_id, + ) + record = await credentials.get_connection(pointer.provider, pointer.connection_name) + if record is None: + await self._global_connections.delete_if_target( + pointer.provider, + pointer.owner_vault_id, + pointer.connection_name, + updated_at=pointer.updated_at, + ) + continue + definition = await self.get_provider(pointer.provider) + summaries.append( + GlobalConnectionSummaryResponse( + provider=record.provider, + provider_display_name=definition.display_name, + connection_name=record.connection_name, + status=record.status.value, + auth_type=record.auth_type.value, + account_label=record.account.label if record.account else None, + api_url=record.api_url, + ) + ) + return sorted(summaries, key=lambda row: (row.provider_display_name.lower(), row.connection_name)) + async def resolve_connection_name(self, provider: str, connection: str | None = None) -> str: """Resolve an optional connection name to the provider default.""" if connection: @@ -248,6 +301,55 @@ async def resolve_connection_name(self, provider: str, connection: str | None = metadata = await self._credentials.get_provider_metadata(provider) return metadata.default_connection if metadata else "default" + async def resolve_effective_connection( + self, + provider: str, + connection: str | None = None, + ) -> EffectiveConnection: + """Resolve the connection used for credential access, including global fallback.""" + if connection is not None and connection != "default": + record = await self.get_connection(provider, connection) + return EffectiveConnection(record=record, credentials=self._credentials, source="local") + + local_default = "default" if connection == "default" else await self.resolve_connection_name(provider, None) + record = await self._credentials.get_connection(provider, local_default) + if record is not None: + return EffectiveConnection(record=record, credentials=self._credentials, source="local") + + pointer = await self._global_connections.get(provider) + if pointer is None: + raise ConnectionNotFoundError( + provider=provider, + connection=local_default, + identity=self._identity or self._principal_id or "account-ui", + ) + + pointer_credentials = self._credentials_for_vault( + pointer.owner_vault_id, + identity=pointer.created_by_identity, + principal_id=pointer.owner_principal_id, + ) + target = await pointer_credentials.get_connection(provider, pointer.connection_name) + if target is None: + await self._global_connections.delete_if_target( + provider, + pointer.owner_vault_id, + pointer.connection_name, + updated_at=pointer.updated_at, + ) + raise ConnectionNotFoundError( + provider=provider, + connection=local_default, + identity=self._identity or self._principal_id or "account-ui", + ) + + write_credentials = self._credentials_for_vault( + pointer.owner_vault_id, + identity=target.identity or pointer.created_by_identity, + principal_id=target.principal_id or pointer.owner_principal_id, + ) + return EffectiveConnection(record=target, credentials=write_credentials, source="global") + async def get_provider_client(self, provider: str) -> ProviderClientRecord | None: """Return stored client credentials for a provider, or None if absent. @@ -351,6 +453,75 @@ async def set_default_connection(self, provider: str, connection: str) -> None: metadata.last_used_connection = connection await self._credentials.save_provider_metadata(metadata) + async def set_global_connection(self, provider: str, connection: str) -> GlobalProviderConnectionRecord: + """Point a provider's global fallback at a local connection owned by this service.""" + self._require_admin( + "set_global_connection", + "setting a global connection requires an admin principal", + provider, + ) + if self._principal_id is None: + raise OperationNotAllowedError( + "set_global_connection", + "setting a global connection requires a principal id", + provider=provider, + ) + + target_connection = ( + await self.resolve_connection_name(provider, None) if connection == "default" else connection + ) + target = await self.get_connection(provider, target_connection) + record = await self._global_connections.upsert( + GlobalProviderConnectionRecord( + provider=provider, + owner_principal_id=self._principal_id, + owner_vault_id=self._vault_id, + connection_name=target.connection_name, + created_by_identity=self._identity, + ) + ) + audit.emit_event( + "provider.global_connection_set", + provider=provider, + connection=target.connection_name, + identity=self._identity, + principal_id=self._principal_id, + owner_vault_id=self._vault_id, + status="success", + ) + return record + + async def unset_global_connection(self, provider: str) -> bool: + """Remove a provider's global fallback pointer.""" + self._require_admin( + "unset_global_connection", + "unsetting a global connection requires an admin principal", + provider, + ) + existing = await self._global_connections.get(provider) + deleted = ( + False + if existing is None + else await self._global_connections.delete_if_target( + provider=provider, + owner_vault_id=existing.owner_vault_id, + connection_name=existing.connection_name, + updated_at=existing.updated_at, + ) + ) + audit.emit_event( + "provider.global_connection_unset", + provider=provider, + identity=self._identity, + principal_id=self._principal_id, + owner_principal_id=existing.owner_principal_id if deleted and existing is not None else None, + owner_vault_id=existing.owner_vault_id if deleted and existing is not None else None, + connection=existing.connection_name if deleted and existing is not None else None, + status="success", + deleted=deleted, + ) + return deleted + # ── Authentication ──────────────────────────────────────────────────── async def get_required_inputs( @@ -619,6 +790,12 @@ async def logout(self, provider: str, connection: str = "default") -> None: record = await self.get_connection(provider, connection) except ConnectionNotFoundError: return + global_pointer = await self._global_connections.get(provider) if self._vault_id is not None else None + remove_global_pointer = ( + global_pointer is not None + and global_pointer.owner_vault_id == self._vault_id + and global_pointer.connection_name == connection + ) if record.auth_type == AuthType.OAUTH2 and (record.access_token or record.refresh_token): handler_cls = _FLOW_HANDLERS.get(definition.flow) @@ -638,6 +815,24 @@ async def logout(self, provider: str, connection: str = "default") -> None: await self._credentials.delete_connection(provider, connection) await self._remove_from_provider_metadata(provider, connection) + if self._vault_id is not None and remove_global_pointer and global_pointer is not None: + deleted = await self._global_connections.delete_if_target( + provider=provider, + owner_vault_id=self._vault_id, + connection_name=connection, + updated_at=global_pointer.updated_at, + ) + if deleted: + audit.emit_event( + "provider.global_connection_unset", + provider=provider, + connection=connection, + identity=self._identity, + principal_id=self._principal_id, + owner_vault_id=self._vault_id, + status="success", + reason="target_deleted", + ) async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> None: """Revoke all tokens for a provider across the given vault IDs. @@ -676,16 +871,30 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non def _for_vault(self, vault_id: str) -> Self: """Return a sibling service scoped to another vault of the same principal.""" return type(self)( - credentials=CredentialRepository( - self.vault, + credentials=self._credentials_for_vault( + vault_id, identity=self._identity, principal_id=self._principal_id, - vault_id=vault_id, ), providers=self._providers, identity=self._identity, principal_id=self._principal_id, principal_role=self._principal_role, + global_connections=self._global_connections, + vault_id=vault_id, + ) + + def _credentials_for_vault( + self, + vault_id: str, + *, + identity: str | None, + principal_id: str | None, + ) -> CredentialRepository: + return CredentialRepository( + self.vault, + identity=identity, + principal_id=principal_id, vault_id=vault_id, ) @@ -826,73 +1035,120 @@ def _get_api_key(self, record: ConnectionRecord) -> str: raise CredentialMissingError("No API key stored in connection record", provider=record.provider) return record.api_key - async def _get_oauth_token(self, record: ConnectionRecord, provider: str, connection: str) -> str: # noqa: PLR0912 + async def _get_oauth_token_with_credentials( + self, + record: ConnectionRecord, + provider: str, + connection: str, + credentials: CredentialRepository, + *, + refresh_with_credentials: bool = True, + ) -> str: # noqa: PLR0912 if record.access_token is None: raise CredentialMissingError("No access token stored", provider=provider) now = utc_now() - if record.expires_at: - near_expiry = record.expires_at - timedelta(seconds=get_server_config().token_near_expiry_seconds) - if now < near_expiry: - return record.access_token + if record.expires_at is None: + return record.access_token - if record.refresh_token: - try: - refreshed = await self._refresh_token(record, provider) - if refreshed.access_token is None: - raise RefreshFailedError("Refreshed record missing access token", provider=provider) - return refreshed.access_token - except RefreshFailedError as exc: - fallback_available = record.expires_at and now < record.expires_at - audit.emit_event( - "provider.refresh_failed", - provider=provider, - connection=connection, - identity=self._identity, - principal_id=self._principal_id, - status="failure", - error=str(exc), - fallback_available=bool(fallback_available), - ) + near_expiry = record.expires_at - timedelta(seconds=get_server_config().token_near_expiry_seconds) + if now < near_expiry: + return record.access_token - if record.expires_at: - duration_secs = int((record.expires_at - now).total_seconds()) - time_desc = format_duration(max(0, duration_secs)) - if fallback_available: - msg = ( - f"Warning: token refresh failed for {provider}/{connection} " - f"— using existing token (expires in {time_desc}). Re-authenticate soon." - ) - else: - msg = ( - f"Warning: token refresh failed for {provider}/{connection} " - f"— token expired {time_desc} ago. Re-authenticate soon." - ) - else: - msg = f"Warning: token refresh failed for {provider}/{connection}. Re-authenticate soon." - - logger.warning(msg) - - if fallback_available: - return record.access_token - - record.status = ConnectionStatus.EXPIRED - await self._credentials.save_connection(record) - raise - else: - if now >= record.expires_at: - record.status = ConnectionStatus.EXPIRED - await self._credentials.save_connection(record) - raise TokenExpiredError(provider=provider) - return record.access_token - else: + if record.refresh_token: + try: + refreshed = ( + await self._refresh_token_with_credentials(record, provider, credentials) + if refresh_with_credentials + else await self._refresh_token(record, provider) + ) + if refreshed.access_token is None: + raise RefreshFailedError("Refreshed record missing access token", provider=provider) + return refreshed.access_token + except RefreshFailedError as exc: + return await self._handle_refresh_failure( + record=record, + connection=connection, + credentials=credentials, + now=now, + error=exc, + ) + + if now >= record.expires_at: + record.status = ConnectionStatus.EXPIRED + await credentials.save_connection(record) + raise TokenExpiredError(provider=provider) + + return record.access_token + + async def _handle_refresh_failure( + self, + *, + record: ConnectionRecord, + connection: str, + credentials: CredentialRepository, + now: datetime, + error: RefreshFailedError, + ) -> str: + fallback_available = bool(record.expires_at and now < record.expires_at) + audit.emit_event( + "provider.refresh_failed", + provider=record.provider, + connection=connection, + identity=self._identity, + principal_id=self._principal_id, + status="failure", + error=str(error), + fallback_available=fallback_available, + ) + + logger.warning( + self._refresh_failure_message(record.provider, connection, record.expires_at, now, fallback_available) + ) + + if fallback_available and record.access_token is not None: return record.access_token + record.status = ConnectionStatus.EXPIRED + await credentials.save_connection(record) + raise error + + @staticmethod + def _refresh_failure_message( + provider: str, + connection: str, + expires_at: datetime | None, + now: datetime, + fallback_available: bool, + ) -> str: + if expires_at is None: + return f"Warning: token refresh failed for {provider}/{connection}. Re-authenticate soon." + + duration_secs = int((expires_at - now).total_seconds()) + time_desc = format_duration(max(0, duration_secs)) + if fallback_available: + return ( + f"Warning: token refresh failed for {provider}/{connection} " + f"— using existing token (expires in {time_desc}). Re-authenticate soon." + ) + return ( + f"Warning: token refresh failed for {provider}/{connection} " + f"— token expired {time_desc} ago. Re-authenticate soon." + ) + async def _refresh_token(self, record: ConnectionRecord, provider_name: str) -> ConnectionRecord: + return await self._refresh_token_with_credentials(record, provider_name, self._credentials) + + async def _refresh_token_with_credentials( + self, + record: ConnectionRecord, + provider_name: str, + credentials: CredentialRepository, + ) -> ConnectionRecord: definition = await self.get_provider(provider_name) - state_record = await self._get_or_create_provider_state(provider_name) + state_record = await self._get_or_create_provider_state_with_credentials(provider_name, credentials) - client_record = await self._credentials.get_provider_client(provider_name) + client_record = await credentials.get_provider_client(provider_name) client_id = client_record.client_id if client_record else None client_secret = client_record.client_secret if client_record else None base_url = record.base_url or (client_record.base_url if client_record else None) @@ -913,41 +1169,65 @@ async def _refresh_token(self, record: ConnectionRecord, provider_name: str) -> except Exception as exc: state_record.last_refresh_at = utc_now() state_record.last_refresh_error = str(exc) - await self._credentials.save_provider_state(state_record) + await credentials.save_provider_state(state_record) if isinstance(exc, RefreshFailedError): raise raise RefreshFailedError(str(exc), provider=provider_name) from exc - await self._credentials.save_connection(record) + await credentials.save_connection(record) now = utc_now() state_record.last_refresh_at = now state_record.last_refresh_error = None - await self._credentials.save_provider_state(state_record) + await credentials.save_provider_state(state_record) logger.info("Token refreshed: provider={}", provider_name) return record - async def _get_or_create_provider_state(self, provider: str) -> ProviderStateRecord: - existing = await self._credentials.get_provider_state(provider) + async def _get_or_create_provider_state_with_credentials( + self, + provider: str, + credentials: CredentialRepository, + ) -> ProviderStateRecord: + existing = await credentials.get_provider_state(provider) if existing: return existing return ProviderStateRecord( provider=provider, - identity=self._identity, - principal_id=self._principal_id, - vault_id=self._vault_id, + identity=credentials.identity, + principal_id=credentials.principal_id, + vault_id=credentials.vault_id, ) async def _get_access_token_from_record(self, record: ConnectionRecord) -> str: + return await self._get_access_token_from_record_with_credentials(record, self._credentials) + + async def _get_access_token_from_record_with_credentials( + self, + record: ConnectionRecord, + credentials: CredentialRepository, + ) -> str: if record.auth_type == AuthType.API_KEY: return self._get_api_key(record) if record.auth_type == AuthType.OAUTH2: - return await self._get_oauth_token(record, record.provider, record.connection_name) + return await self._get_oauth_token_with_credentials( + record, + record.provider, + record.connection_name, + credentials, + ) raise CredentialMissingError(f"Unsupported auth type: {record.auth_type}", provider=record.provider) async def _get_auth_headers_from_record( self, record: ConnectionRecord, definition: ProviderDefinition + ) -> dict[str, str]: + return await self._get_auth_headers_from_record_with_credentials(record, definition, self._credentials) + + async def _get_auth_headers_from_record_with_credentials( + self, + record: ConnectionRecord, + definition: ProviderDefinition, + credentials: CredentialRepository, ) -> dict[str, str]: if record.auth_type == AuthType.BROWSER: if not record.credentials: @@ -964,7 +1244,7 @@ async def _get_auth_headers_from_record( headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in record.credentials.items()) return headers - token = await self._get_access_token_from_record(record) + token = await self._get_access_token_from_record_with_credentials(record, credentials) if record.auth_type == AuthType.OAUTH2: return {"Authorization": f"Bearer {token}"} diff --git a/src/authsome/server/proxy_catalog.py b/src/authsome/server/proxy_catalog.py index 1f84d567..96ed5b4d 100644 --- a/src/authsome/server/proxy_catalog.py +++ b/src/authsome/server/proxy_catalog.py @@ -42,6 +42,48 @@ def _route_entries(definition: Any, connection_name: str, api_url: str | list[st return [{**base_entry, "api_url": api_url} for api_url in api_urls] +async def _connected_routes(auth: CredentialService) -> list[dict[str, Any]]: + routes: list[dict[str, Any]] = [] + routed_providers: set[str] = set() + for provider_group in await auth.list_connections(): + provider_name = provider_group["name"] + selected_connections = provider_group["connections"] + + try: + definition = await auth.get_provider(provider_name) + except Exception: + continue + if not definition.api_url: + continue + + default_conn = next((c for c in selected_connections if c.get("is_default")), None) + if not default_conn: + continue + + routes.extend( + _route_entries( + definition, + default_conn.get("connection_name", "default"), + default_conn.get("api_url") or definition.api_url, + ) + ) + routed_providers.add(provider_name) + + for global_connection in await auth.list_global_connection_summaries(): + provider_name = global_connection.provider + if provider_name in routed_providers: + continue + try: + definition = await auth.get_provider(provider_name) + except Exception: + continue + if not definition.api_url: + continue + routes.extend(_route_entries(definition, "default", global_connection.api_url or definition.api_url)) + routed_providers.add(provider_name) + return routes + + async def build_proxy_routes(auth: CredentialService, scope: str = "connected") -> dict[str, Any]: """Build the list of routes for proxy routing. @@ -55,28 +97,7 @@ async def build_proxy_routes(auth: CredentialService, scope: str = "connected") routes = [] if scope == "connected": - for provider_group in await auth.list_connections(): - provider_name = provider_group["name"] - selected_connections = provider_group["connections"] - - try: - definition = await auth.get_provider(provider_name) - except Exception: - continue - if not definition.api_url: - continue - - default_conn = next((c for c in selected_connections if c.get("is_default")), None) - if not default_conn: - continue - - routes.extend( - _route_entries( - definition, - default_conn.get("connection_name", "default"), - default_conn.get("api_url") or definition.api_url, - ) - ) + routes = await _connected_routes(auth) else: # configured for definition in await auth.list_providers(): if not definition.api_url: diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 5e3f625c..98aa7d2e 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -49,6 +49,7 @@ def build_auth_service( return CredentialService( credentials=credentials, providers=request.app.state.provider_repository, + global_connections=request.app.state.store.global_provider_connections, identity=identity, principal_id=principal_id, principal_role=principal_role, diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index a0675dbc..b2a6a813 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -28,6 +28,7 @@ async def _connection_detail( connection: str, *, can_set_default: bool, + can_set_global: bool, ) -> ConnectionDetailResponse: definition = await auth.get_provider(provider) record = await auth.get_connection(provider, connection) @@ -54,6 +55,7 @@ async def _connection_detail( credentials=dict(record.credentials or {}), ), can_set_default=can_set_default, + can_set_global=can_set_global, ) @@ -62,6 +64,7 @@ async def list_connections(auth: CredentialService = Depends(get_daemon_or_brows by_source = await auth.list_providers_by_source() return { "connections": await auth.list_connections(), + "global_connections": [row.model_dump(mode="json") for row in await auth.list_global_connection_summaries()], "by_source": { source: [provider.model_dump(mode="json") for provider in providers] for source, providers in by_source.items() @@ -94,6 +97,7 @@ async def get_connection_detail( provider, connection, can_set_default=target.principal_id == auth.principal_id, + can_set_global=auth.principal_role == PrincipalRole.ADMIN and target.principal_id == auth.principal_id, ) @@ -158,6 +162,51 @@ async def set_default_connection( return {"status": "ok", "provider": provider, "default_connection": connection} +@router.post("/connections/{provider}/{connection}/global") +async def set_global_connection( + provider: str, + connection: str, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + record = await auth.set_global_connection(provider, connection) + capture_event( + _actor(auth), + "connection made global", + { + "provider": provider, + "connection": connection, + "principal_id": auth.principal_id, + }, + ) + return { + "status": "ok", + "provider": record.provider, + "connection_name": record.connection_name, + } + + +@router.delete("/connections/{provider}/global") +async def unset_global_connection( + provider: str, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + deleted = await auth.unset_global_connection(provider) + capture_event( + _actor(auth), + "global connection removed", + { + "provider": provider, + "principal_id": auth.principal_id, + "deleted": deleted, + }, + ) + return {"status": "ok", "provider": provider, "deleted": deleted} + + @router.post("/credentials/export") async def export_credentials(body: dict, auth: CredentialService = Depends(get_protected_auth_service)): provider = body.get("provider") diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index 74bb15b0..c515994b 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -94,6 +94,7 @@ class CredentialResolutionResponse(BaseModel): connection: str headers: dict[str, str] expires_at: datetime | None = None + source: Literal["local", "global"] class ProviderRoute(BaseModel): @@ -126,6 +127,18 @@ class PrincipalVaultBindingRecord(BaseModel): updated_at: datetime = Field(default_factory=utc_now) +class GlobalProviderConnectionRecord(BaseModel): + """Server-owned pointer to a vault-local connection used as a global fallback.""" + + provider: str + owner_principal_id: str + owner_vault_id: str + connection_name: str + created_by_identity: str | None = None + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + class ConnectionSummaryResponse(BaseModel): provider: str provider_display_name: str @@ -136,6 +149,17 @@ class ConnectionSummaryResponse(BaseModel): principal_id: str | None = None +class GlobalConnectionSummaryResponse(BaseModel): + provider: str + provider_display_name: str + connection_name: str + status: str + auth_type: str + account_label: str | None = None + api_url: str | None = None + source: Literal["global"] = "global" + + class ProviderClientResponse(BaseModel): client_id: str | None = None client_secret: str | None = None @@ -201,3 +225,4 @@ class ConnectionDetailResponse(BaseModel): account: dict[str, Any] | None = None secrets: ConnectionSecretsResponse = Field(default_factory=ConnectionSecretsResponse) can_set_default: bool = False + can_set_global: bool = False diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 738a6f67..a076d57b 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -73,6 +73,18 @@ async def execute(self, sql: str, params: Sequence[Any] = ()) -> None: return await self._connection.execute(self._sql(sql), *params) + async def execute_rowcount(self, sql: str, params: Sequence[Any] = ()) -> int: + if self.backend == "sqlite": + cursor = await self._connection.execute(sql, params) + await self._connection.commit() + rowcount = cursor.rowcount + await cursor.close() + return rowcount + + status = await self._connection.execute(self._sql(sql), *params) + _, _, count = status.partition(" ") + return int(count) if count else 0 + async def execute_many(self, statements: Sequence[str]) -> None: for statement in statements: await self.execute(statement) @@ -181,6 +193,15 @@ def build_schema(backend: StoreBackend) -> list[str]: "CREATE TABLE IF NOT EXISTS custom_provider_definitions (" "name TEXT PRIMARY KEY, definition_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" ")", + "CREATE TABLE IF NOT EXISTS global_provider_connections (" + "provider TEXT PRIMARY KEY, " + "owner_principal_id TEXT NOT NULL REFERENCES principals(principal_id) ON DELETE CASCADE, " + "owner_vault_id TEXT NOT NULL REFERENCES vaults(vault_id) ON DELETE CASCADE, " + "connection_name TEXT NOT NULL, " + "created_by_identity TEXT, " + "created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL" + ")", "CREATE TABLE IF NOT EXISTS audit_events (" "event_id TEXT PRIMARY KEY, " "timestamp TEXT NOT NULL, " @@ -206,6 +227,7 @@ async def create_server_store(home: Path | None = None, database_url: str | None """Create the server-owned relational Store.""" from authsome.server.store.repositories import ( # noqa: PLC0415 AuditEventRegistry, + GlobalProviderConnectionRegistry, IdentityClaimRegistry, IdentityRegistry, PrincipalRegistry, @@ -226,6 +248,7 @@ async def create_server_store(home: Path | None = None, database_url: str | None vaults=VaultRegistry(database), identity_claims=IdentityClaimRegistry(database), principal_vault_bindings=PrincipalVaultBindingRegistry(database), + global_provider_connections=GlobalProviderConnectionRegistry(database), server_config=ServerConfigRepository(database), provider_definitions=ProviderDefinitionRepository(database), audit_events=AuditEventRegistry(database), diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index d92e6a5a..d1a029ae 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -29,7 +29,7 @@ PrincipalRole, ) from authsome.identity.registry import IdentityRegistration -from authsome.server.schemas import PrincipalVaultBindingRecord, VaultRecord +from authsome.server.schemas import GlobalProviderConnectionRecord, PrincipalVaultBindingRecord, VaultRecord from authsome.server.store.database import StoreBackend, StoreDatabase from authsome.utils import utc_now @@ -595,6 +595,90 @@ def _record(row: dict[str, Any]) -> PrincipalVaultBindingRecord: ) +class GlobalProviderConnectionRegistry: + """Relational registry for global provider connection pointers.""" + + def __init__(self, database: StoreDatabase) -> None: + self._db = database + + async def get(self, provider: str) -> GlobalProviderConnectionRecord | None: + row = await self._db.fetch_one("SELECT * FROM global_provider_connections WHERE provider = ?", [provider]) + return self._record(row) if row else None + + async def list_all(self) -> list[GlobalProviderConnectionRecord]: + rows = await self._db.fetch_all("SELECT * FROM global_provider_connections ORDER BY provider") + return [self._record(row) for row in rows] + + async def upsert(self, record: GlobalProviderConnectionRecord) -> GlobalProviderConnectionRecord: + existing = await self.get(record.provider) + updated_at = utc_now() + created_at = existing.created_at if existing is not None else updated_at + stored = record.model_copy(update={"created_at": created_at, "updated_at": updated_at}) + await self._db.execute( + "INSERT INTO global_provider_connections " + "(provider, owner_principal_id, owner_vault_id, connection_name, created_by_identity, created_at, " + "updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider) DO UPDATE SET " + "owner_principal_id = excluded.owner_principal_id, " + "owner_vault_id = excluded.owner_vault_id, " + "connection_name = excluded.connection_name, " + "created_by_identity = excluded.created_by_identity, " + "created_at = excluded.created_at, " + "updated_at = excluded.updated_at", + [ + stored.provider, + stored.owner_principal_id, + stored.owner_vault_id, + stored.connection_name, + stored.created_by_identity, + _dump_dt(stored.created_at), + _dump_dt(stored.updated_at), + ], + ) + return stored + + async def delete(self, provider: str) -> bool: + existing = await self.get(provider) + if existing is None: + return False + await self._db.execute("DELETE FROM global_provider_connections WHERE provider = ?", [provider]) + return True + + async def delete_if_target( + self, + provider: str, + owner_vault_id: str, + connection_name: str, + *, + updated_at: datetime | None = None, + ) -> bool: + sql = ( + "DELETE FROM global_provider_connections WHERE provider = ? AND owner_vault_id = ? AND connection_name = ?" + ) + params: list[Any] = [provider, owner_vault_id, connection_name] + if updated_at is not None: + sql = f"{sql} AND updated_at = ?" + params.append(_dump_dt(updated_at)) + deleted = await self._db.execute_rowcount( + sql, + params, + ) + return deleted > 0 + + @staticmethod + def _record(row: dict[str, Any]) -> GlobalProviderConnectionRecord: + return GlobalProviderConnectionRecord( + provider=row["provider"], + owner_principal_id=row["owner_principal_id"], + owner_vault_id=row["owner_vault_id"], + connection_name=row["connection_name"], + created_by_identity=row["created_by_identity"], + created_at=_dt(row["created_at"]), + updated_at=_dt(row["updated_at"]), + ) + + class ServerConfigRepository: """Relational server config repository.""" @@ -665,6 +749,7 @@ class ServerStore: vaults: VaultRegistry identity_claims: IdentityClaimRegistry principal_vault_bindings: PrincipalVaultBindingRegistry + global_provider_connections: GlobalProviderConnectionRegistry server_config: ServerConfigRepository provider_definitions: ProviderDefinitionRepository audit_events: AuditEventRegistry diff --git a/tests/auth/test_browser_service.py b/tests/auth/test_browser_service.py index 5f6980be..9385837d 100644 --- a/tests/auth/test_browser_service.py +++ b/tests/auth/test_browser_service.py @@ -1,7 +1,7 @@ """Tests for BROWSER auth handling in credential_service.py.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -39,6 +39,7 @@ def _svc() -> CredentialService: return CredentialService( credentials=CredentialRepository(vault, identity="agent", principal_id="p1", vault_id="v1"), providers=StaticProviders(), + global_connections=AsyncMock(), identity="agent", principal_id="p1", vault_id="v1", diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py index fc3c3236..dbe6d88c 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -5,12 +5,17 @@ import pytest import pytest_asyncio +from pydantic import ValidationError -from authsome.auth.models.connection import ConnectionRecord -from authsome.auth.models.enums import AuthType, ConnectionStatus -from authsome.errors import RefreshFailedError +from authsome.auth.models.connection import ConnectionRecord, ProviderMetadataRecord +from authsome.auth.models.enums import AuthType, ConnectionStatus, FlowType +from authsome.auth.models.provider import OAuthConfig, ProviderDefinition +from authsome.errors import ConnectionNotFoundError, OperationNotAllowedError, RefreshFailedError +from authsome.identity.principal import PrincipalRole from authsome.server.credential_repository import CredentialRepository from authsome.server.credential_service import CredentialService +from authsome.server.dependencies import create_vault +from authsome.server.schemas import CredentialResolutionResponse, GlobalProviderConnectionRecord from authsome.server.store import create_server_store from authsome.server.store.repositories import ServerAuditLog from authsome.utils import utc_now @@ -35,14 +40,101 @@ async def delete_custom(self, name: str) -> bool: return False +class StaticProviders: + async def get(self, name: str): # noqa: ANN001, ANN201 + return ProviderDefinition( + name=name, + display_name=name.title(), + auth_type=AuthType.OAUTH2, + flow=FlowType.PKCE, + oauth=OAuthConfig( + authorization_url="https://example.test/oauth", + token_url="https://example.test/token", + scopes=[], + ), + ) + + async def list(self): # noqa: ANN201 + return [await self.get("github")] + + async def list_by_source(self): # noqa: ANN201 + return {"bundled": [await self.get("github")], "custom": []} + + async def save_custom(self, definition, *, force: bool = False) -> None: # noqa: ANN001 + raise AssertionError("unexpected provider save") + + async def delete_custom(self, name: str) -> bool: + return False + + async def is_custom(self, name: str) -> bool: + return False + + +class MemoryGlobalConnections: + def __init__(self) -> None: + self.pointer: GlobalProviderConnectionRecord | None = None + + async def get(self, provider: str) -> GlobalProviderConnectionRecord | None: + if self.pointer is None or self.pointer.provider != provider: + return None + return self.pointer + + async def upsert(self, record: GlobalProviderConnectionRecord) -> GlobalProviderConnectionRecord: + self.pointer = record + return record + + async def delete(self, provider: str) -> bool: + if self.pointer is None or self.pointer.provider != provider: + return False + self.pointer = None + return True + + async def delete_if_target( + self, + provider: str, + owner_vault_id: str, + connection_name: str, + *, + updated_at=None, # noqa: ANN001 + ) -> bool: + if ( + self.pointer is None + or self.pointer.provider != provider + or self.pointer.owner_vault_id != owner_vault_id + or self.pointer.connection_name != connection_name + or (updated_at is not None and self.pointer.updated_at != updated_at) + ): + return False + self.pointer = None + return True + + +class SwappingMemoryGlobalConnections(MemoryGlobalConnections): + def __init__( + self, + *, + initial_pointer: GlobalProviderConnectionRecord, + replacement_pointer: GlobalProviderConnectionRecord, + ) -> None: + super().__init__() + self.pointer = initial_pointer + self._replacement_pointer = replacement_pointer + self._swapped = False + + async def get(self, provider: str) -> GlobalProviderConnectionRecord | None: + pointer = await super().get(provider) + if pointer is not None and not self._swapped: + self.pointer = self._replacement_pointer + self._swapped = True + return pointer + return pointer + + def _credentials( vault, *, identity: str | None = "agent-a", principal_id: str | None = None, vault_id: str = "vault_default" ): # noqa: ANN001 return CredentialRepository(vault, identity=identity, principal_id=principal_id, vault_id=vault_id) - async def is_custom(self, name: str) -> bool: - return False - @pytest.mark.asyncio class TestAuthServiceRefreshLogs: @@ -64,6 +156,7 @@ def service(self) -> CredentialService: return CredentialService( credentials=_credentials(mock_vault, identity="test-profile", vault_id="test-vault"), providers=EmptyProviders(), + global_connections=mock.AsyncMock(), identity="test-profile", vault_id="test-vault", ) @@ -90,7 +183,13 @@ async def test_refresh_failure_fallback_available(self, audit_log: ServerAuditLo mock.patch("loguru.logger.warning") as mock_logger, ): # Exercise - token = await service._get_oauth_token(record, provider="github", connection="default") + token = await service._get_oauth_token_with_credentials( + record, + "github", + "default", + service._credentials, + refresh_with_credentials=False, + ) # 1. Should yield fallback token assert token == "original-token" @@ -135,7 +234,13 @@ async def test_refresh_failure_expired(self, audit_log: ServerAuditLog, service: ): # Exercise - should re-raise exception as there is no fallback with pytest.raises(RefreshFailedError): - await service._get_oauth_token(record, provider="github", connection="default") + await service._get_oauth_token_with_credentials( + record, + "github", + "default", + service._credentials, + refresh_with_credentials=False, + ) # 1. Warning still emitted even without fallback mock_logger.assert_called_once() @@ -156,6 +261,7 @@ def test_auth_service_allows_missing_identity() -> None: service = CredentialService( credentials=_credentials(mock_vault, identity=None, principal_id="principal_1"), providers=EmptyProviders(), + global_connections=mock.AsyncMock(), identity=None, principal_id="principal_1", vault_id="vault_default", @@ -168,6 +274,7 @@ def test_auth_service_scopes_collection_by_vault_id() -> None: service = CredentialService( credentials=_credentials(mock_vault, identity="agent-a", principal_id="principal_1"), providers=EmptyProviders(), + global_connections=mock.AsyncMock(), identity="agent-a", principal_id="principal_1", vault_id="vault_default", @@ -175,12 +282,603 @@ def test_auth_service_scopes_collection_by_vault_id() -> None: assert service._credentials.collection == "vault:vault_default" +def test_auth_service_exposes_global_connection_registry() -> None: + mock_vault = mock.AsyncMock() + global_connections = mock.AsyncMock() + service = CredentialService( + credentials=_credentials(mock_vault, identity="agent-a", principal_id="principal_1"), + providers=EmptyProviders(), + global_connections=global_connections, + identity="agent-a", + principal_id="principal_1", + vault_id="vault_default", + ) + + assert service.global_connections is global_connections + + def test_auth_service_requires_providers() -> None: mock_vault = mock.AsyncMock() with pytest.raises(TypeError): CredentialService( credentials=_credentials(mock_vault, identity="agent-a"), + global_connections=mock.AsyncMock(), identity="agent-a", vault_id="vault_default", ) # type: ignore[call-arg] + + +@pytest.mark.asyncio +async def test_resolve_credentials_uses_local_default_before_global(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-a", principal_id="principal_1", vault_id="vault_user"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-a", + principal_id="principal_1", + vault_id="vault_user", + ) + owner_credentials = _credentials( + vault, + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + ) + + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-a", + principal_id="principal_1", + vault_id="vault_user", + connection_name="default", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="local-token", + ) + ) + await owner_credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="shared", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="global-token", + ) + ) + await global_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_admin", + owner_vault_id="vault_admin", + connection_name="shared", + created_by_identity="agent-admin", + ) + ) + + resolved = await service.resolve_credentials(provider="github") + + assert resolved["headers"] == {"Authorization": "Bearer local-token"} + assert resolved["source"] == "local" + + +@pytest.mark.asyncio +async def test_resolve_credentials_falls_back_to_global_for_missing_default(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-a", principal_id="principal_1", vault_id="vault_user"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-a", + principal_id="principal_1", + vault_id="vault_user", + ) + owner_service = service._for_vault("vault_admin") + + await owner_service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-a", + principal_id="principal_1", + vault_id="vault_admin", + connection_name="shared", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="global-token", + ) + ) + await global_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_admin", + owner_vault_id="vault_admin", + connection_name="shared", + created_by_identity="agent-admin", + ) + ) + + resolved = await service.resolve_credentials(provider="github") + + assert resolved["headers"] == {"Authorization": "Bearer global-token"} + assert resolved["source"] == "global" + + response = CredentialResolutionResponse.model_validate(resolved) + + assert response.source == "global" + + +def test_credential_resolution_response_requires_source() -> None: + with pytest.raises(ValidationError): + CredentialResolutionResponse.model_validate( + { + "provider": "github", + "connection": "shared", + "headers": {"Authorization": "Bearer token"}, + "expires_at": None, + } + ) + + +@pytest.mark.asyncio +async def test_named_missing_connection_does_not_fall_back_to_global(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-a", principal_id="principal_1", vault_id="vault_user"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-a", + principal_id="principal_1", + vault_id="vault_user", + ) + owner_service = service._for_vault("vault_admin") + + await owner_service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-a", + principal_id="principal_1", + vault_id="vault_admin", + connection_name="shared", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="global-token", + ) + ) + await global_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_admin", + owner_vault_id="vault_admin", + connection_name="shared", + created_by_identity="agent-admin", + ) + ) + + with pytest.raises(ConnectionNotFoundError): + await service.resolve_credentials(provider="github", connection="work") + + +@pytest.mark.asyncio +async def test_resolve_credentials_global_refresh_writes_state_to_owner_vault(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-a", principal_id="principal_1", vault_id="vault_user"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-a", + principal_id="principal_1", + vault_id="vault_user", + ) + owner_credentials = _credentials( + vault, + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + ) + expires_at = utc_now() + timedelta(seconds=1) + + await owner_credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="shared", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="stale-token", + refresh_token="refresh-token", + expires_at=expires_at, + ) + ) + await global_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_admin", + owner_vault_id="vault_admin", + connection_name="shared", + created_by_identity="agent-admin", + ) + ) + captured_vault_ids: list[str] = [] + + class FakePkceFlow: + def refresh(self, *, provider, record, client_id=None, client_secret=None): # noqa: ANN001, ANN201 + del provider, client_id, client_secret + return record.model_copy( + update={ + "access_token": "fresh-token", + "expires_at": utc_now() + timedelta(hours=1), + } + ) + + original_refresh = CredentialService._refresh_token_with_credentials + + async def recording_refresh(self, record, provider_name, credentials): # noqa: ANN001, ANN201 + captured_vault_ids.append(credentials.vault_id) + return await original_refresh(self, record, provider_name, credentials) + + with ( + mock.patch.dict("authsome.server.credential_service._FLOW_HANDLERS", {FlowType.PKCE: FakePkceFlow}), + mock.patch.object(CredentialService, "_refresh_token_with_credentials", recording_refresh), + ): + resolved = await service.resolve_credentials(provider="github") + + owner_state = await owner_credentials.get_provider_state("github") + user_state = await service._credentials.get_provider_state("github") + owner_connection = await owner_credentials.get_connection("github", "shared") + + assert resolved["headers"] == {"Authorization": "Bearer fresh-token"} + assert captured_vault_ids == ["vault_admin"] + assert owner_state is not None + assert owner_state.vault_id == "vault_admin" + assert user_state is None + assert owner_connection is not None + assert owner_connection.access_token == "fresh-token" + assert owner_connection.identity == "agent-admin" + assert owner_connection.principal_id == "principal_admin" + assert owner_state.identity == "agent-admin" + assert owner_state.principal_id == "principal_admin" + + +@pytest.mark.asyncio +async def test_admin_can_make_own_connection_global(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="default", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="admin-token", + ) + ) + + pointer = await service.set_global_connection("github", "default") + + assert pointer.provider == "github" + assert pointer.owner_principal_id == "principal_admin" + assert pointer.owner_vault_id == "vault_admin" + assert pointer.connection_name == "default" + assert global_connections.pointer == pointer + + +@pytest.mark.asyncio +async def test_set_global_connection_resolves_local_default_alias(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="work", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="admin-token", + ) + ) + await service._credentials.save_provider_metadata( + ProviderMetadataRecord( + provider="github", + principal_id="principal_admin", + vault_id="vault_admin", + default_connection="work", + connection_names=["work"], + ) + ) + + pointer = await service.set_global_connection("github", "default") + + assert pointer.connection_name == "work" + assert global_connections.pointer == pointer + + +@pytest.mark.asyncio +async def test_non_admin_cannot_make_connection_global(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + service = CredentialService( + credentials=_credentials(vault, identity="agent-user", principal_id="principal_user", vault_id="vault_user"), + providers=StaticProviders(), + global_connections=MemoryGlobalConnections(), + identity="agent-user", + principal_id="principal_user", + principal_role=PrincipalRole.USER, + vault_id="vault_user", + ) + + with pytest.raises(OperationNotAllowedError): + await service.set_global_connection("github", "default") + + +@pytest.mark.asyncio +async def test_admin_can_unset_global_connection(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + global_connections.pointer = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="someone-else", + owner_vault_id="vault_elsewhere", + connection_name="shared", + created_by_identity="agent-other", + ) + + deleted = await service.unset_global_connection("github") + + assert deleted is True + assert global_connections.pointer is None + + +@pytest.mark.asyncio +async def test_cross_admin_unset_global_connection_audits_removed_target_fields(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + global_connections.pointer = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_owner", + owner_vault_id="vault_owner", + connection_name="shared", + created_by_identity="agent-owner", + ) + + with mock.patch("authsome.server.credential_service.audit.emit_event") as emit_event: + deleted = await service.unset_global_connection("github") + + assert deleted is True + emit_event.assert_called_once_with( + "provider.global_connection_unset", + provider="github", + identity="agent-admin", + principal_id="principal_admin", + owner_principal_id="principal_owner", + owner_vault_id="vault_owner", + connection="shared", + status="success", + deleted=True, + ) + + +@pytest.mark.asyncio +async def test_unset_global_connection_does_not_delete_repointed_pointer(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + initial_pointer = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_old", + owner_vault_id="vault_old", + connection_name="shared-old", + created_by_identity="agent-old", + ) + replacement_pointer = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_new", + owner_vault_id="vault_new", + connection_name="shared-new", + created_by_identity="agent-new", + ) + global_connections = SwappingMemoryGlobalConnections( + initial_pointer=initial_pointer, + replacement_pointer=replacement_pointer, + ) + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + with mock.patch("authsome.server.credential_service.audit.emit_event") as emit_event: + deleted = await service.unset_global_connection("github") + + assert deleted is False + assert global_connections.pointer == replacement_pointer + emit_event.assert_called_once_with( + "provider.global_connection_unset", + provider="github", + identity="agent-admin", + principal_id="principal_admin", + owner_principal_id=None, + owner_vault_id=None, + connection=None, + status="success", + deleted=False, + ) + + +@pytest.mark.asyncio +async def test_unset_global_connection_does_not_delete_reset_same_target_pointer(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + first_updated_at = utc_now() + second_updated_at = first_updated_at + timedelta(seconds=1) + initial_pointer = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id="principal_owner", + owner_vault_id="vault_owner", + connection_name="shared", + created_by_identity="agent-owner", + updated_at=first_updated_at, + ) + replacement_pointer = initial_pointer.model_copy(update={"updated_at": second_updated_at}) + global_connections = SwappingMemoryGlobalConnections( + initial_pointer=initial_pointer, + replacement_pointer=replacement_pointer, + ) + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + with mock.patch("authsome.server.credential_service.audit.emit_event") as emit_event: + deleted = await service.unset_global_connection("github") + + assert deleted is False + assert global_connections.pointer == replacement_pointer + emit_event.assert_called_once_with( + "provider.global_connection_unset", + provider="github", + identity="agent-admin", + principal_id="principal_admin", + owner_principal_id=None, + owner_vault_id=None, + connection=None, + status="success", + deleted=False, + ) + + +@pytest.mark.asyncio +async def test_logout_of_global_target_removes_pointer(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="default", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="admin-token", + ) + ) + await service.set_global_connection("github", "default") + + await service.logout("github", "default") + + assert global_connections.pointer is None + + +@pytest.mark.asyncio +async def test_logout_of_other_local_connection_keeps_pointer(tmp_path) -> None: # noqa: ANN001 + vault = await create_vault(tmp_path) + global_connections = MemoryGlobalConnections() + service = CredentialService( + credentials=_credentials(vault, identity="agent-admin", principal_id="principal_admin", vault_id="vault_admin"), + providers=StaticProviders(), + global_connections=global_connections, + identity="agent-admin", + principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, + vault_id="vault_admin", + ) + + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="default", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="admin-token", + ) + ) + await service._credentials.save_connection( + ConnectionRecord( + provider="github", + identity="agent-admin", + principal_id="principal_admin", + vault_id="vault_admin", + connection_name="other", + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token="other-token", + ) + ) + await service.set_global_connection("github", "default") + + await service.logout("github", "other") + + assert global_connections.pointer is not None + assert global_connections.pointer.connection_name == "default" diff --git a/tests/auth/test_service_provider_clients.py b/tests/auth/test_service_provider_clients.py index 7a0fcd12..d2d1dc48 100644 --- a/tests/auth/test_service_provider_clients.py +++ b/tests/auth/test_service_provider_clients.py @@ -48,7 +48,12 @@ def _service(vault, **kwargs) -> CredentialService: # noqa: ANN001, ANN003 principal_id = kwargs.get("principal_id") vault_id = kwargs.get("vault_id", "vault_default") credentials = CredentialRepository(vault, identity=identity, principal_id=principal_id, vault_id=vault_id) - return CredentialService(credentials=credentials, providers=EmptyProviders(), **kwargs) + return CredentialService( + credentials=credentials, + providers=EmptyProviders(), + global_connections=kwargs.pop("global_connections", mock.AsyncMock()), + **kwargs, + ) def _make_provider(*, flow: FlowType = FlowType.PKCE) -> ProviderDefinition: @@ -397,6 +402,7 @@ async def test_revoke_local_deletes_shared_client_and_all_identity_connections(t vault_id=primary_vault.vault_id, ), providers=ProviderRepository(store.provider_definitions), + global_connections=store.global_provider_connections, identity="steady-wisely-boldly-0042", principal_id="principal_1", principal_role=PrincipalRole.ADMIN, diff --git a/tests/auth/test_service_provider_definitions.py b/tests/auth/test_service_provider_definitions.py index 3d4d07af..0141ff6f 100644 --- a/tests/auth/test_service_provider_definitions.py +++ b/tests/auth/test_service_provider_definitions.py @@ -37,6 +37,7 @@ async def test_custom_provider_definition_is_stored_in_store_not_vault(tmp_path: vault_id="vault_test", ), providers=ProviderRepository(store.provider_definitions), + global_connections=store.global_provider_connections, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN, vault_id="vault_test", @@ -63,6 +64,7 @@ async def test_provider_client_credentials_still_use_vault(tmp_path: Path) -> No vault_id="vault_test", ), providers=ProviderRepository(store.provider_definitions), + global_connections=store.global_provider_connections, identity="steady-wisely-boldly-0042", vault_id="vault_test", ) diff --git a/tests/proxy/test_proxy.py b/tests/proxy/test_proxy.py index 434363d4..22410bd6 100644 --- a/tests/proxy/test_proxy.py +++ b/tests/proxy/test_proxy.py @@ -27,6 +27,7 @@ async def _make_auth(tmp_path: Path) -> AuthLayer: return AuthLayer( credentials=CredentialRepository(vault, identity=identity, principal_id=None, vault_id=identity), providers=ProviderRepository(store.provider_definitions), + global_connections=store.global_provider_connections, identity=identity, vault_id=identity, ) diff --git a/tests/server/test_connection_details.py b/tests/server/test_connection_details.py index fcb8b3ae..370b7782 100644 --- a/tests/server/test_connection_details.py +++ b/tests/server/test_connection_details.py @@ -68,6 +68,7 @@ def _put_connection( def test_user_can_read_own_connection_detail(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=user.handle)) _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "default") @@ -81,6 +82,7 @@ def test_user_can_read_own_connection_detail(monkeypatch, tmp_path: Path) -> Non assert body["connection_name"] == "default" assert body["secrets"]["access_token"] == "access-default" assert body["secrets"]["refresh_token"] == "refresh-default" + assert body["can_set_global"] is False def test_non_admin_cannot_read_other_principal_connection(monkeypatch, tmp_path: Path) -> None: @@ -109,6 +111,22 @@ def test_admin_can_read_other_principal_connection(monkeypatch, tmp_path: Path) assert response.status_code == status.HTTP_200_OK assert response.json()["principal_id"] == user_ctx.principal_id assert response.json()["secrets"]["access_token"] == "access-user-main" + assert response.json()["can_set_global"] is False + + +def test_admin_can_make_own_connection_global_from_detail(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "default") + response = client.get( + "/api/connections/github/default/detail", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/default/detail", handle=admin.handle), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["can_set_global"] is True def test_admin_logout_targets_other_principal_connection(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/server/test_global_connections.py b/tests/server/test_global_connections.py new file mode 100644 index 00000000..ee980ec6 --- /dev/null +++ b/tests/server/test_global_connections.py @@ -0,0 +1,222 @@ +import asyncio +import json +from pathlib import Path + +from fastapi import status +from fastapi.testclient import TestClient + +from authsome.auth.models.connection import AccountInfo, ConnectionRecord, ProviderMetadataRecord +from authsome.auth.models.enums import AuthType, ConnectionStatus +from authsome.cli.identity import RuntimeIdentity +from authsome.server.credential_repository import build_store_key +from tests.server.helpers import create_server_test_client +from tests.server.test_pop_auth import _auth_header + + +def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> RuntimeIdentity: + identity = RuntimeIdentity.create(tmp_path, handle) + response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == status.HTTP_200_OK + + async def claim() -> None: + principal = await client.app.state.store.principals.create_by_email(email) + vault = await client.app.state.store.vaults.create_default() + await client.app.state.store.principal_vault_bindings.bind_default(principal.principal_id, vault.vault_id) + await client.app.state.store.identity_claims.claim_identity(identity.handle, principal.principal_id) + await client.app.state.store.identity_claims.accept_claim(identity.handle) + + asyncio.run(claim()) + return identity + + +def _put_connection( + client: TestClient, + principal_id: str, + vault_id: str, + connection: str, + *, + api_url: str | None = None, +) -> None: + collection = f"vault:{vault_id}" + key = build_store_key(vault=vault_id, provider="github", connection=connection, record_type="connection") + record = ConnectionRecord( + provider="github", + principal_id=principal_id, + vault_id=vault_id, + connection_name=connection, + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token=f"access-{connection}", + refresh_token=f"refresh-{connection}", + token_type="bearer", + scopes=["repo"], + account=AccountInfo(label="GitHub Admin"), + api_url=api_url, + ) + metadata_key = build_store_key(vault=vault_id, provider="github", record_type="metadata") + metadata = ProviderMetadataRecord( + principal_id=principal_id, + vault_id=vault_id, + provider="github", + default_connection=connection, + connection_names=[connection], + last_used_connection=connection, + ) + asyncio.run(client.app.state.vault.put(key, record.model_dump_json(), collection=collection)) + asyncio.run(client.app.state.vault.put(metadata_key, metadata.model_dump_json(), collection=collection)) + + +def test_admin_can_make_connection_global_and_user_sees_redacted_summary(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "default") + + set_response = client.post( + "/api/connections/github/default/global", + headers=_auth_header(tmp_path, "POST", "/api/connections/github/default/global", handle=admin.handle), + ) + list_response = client.get( + "/api/connections", + headers=_auth_header(tmp_path, "GET", "/api/connections", handle=user.handle), + ) + detail_path = f"/api/connections/github/default/detail?principal={admin_ctx.principal_id}" + detail_response = client.get( + detail_path, + headers=_auth_header(tmp_path, "GET", detail_path, handle=user.handle), + ) + raw_response = client.get( + "/api/connections/github/default", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/default", handle=user.handle), + ) + + assert set_response.status_code == status.HTTP_200_OK + assert set_response.json() == {"status": "ok", "provider": "github", "connection_name": "default"} + assert list_response.status_code == status.HTTP_200_OK + body = list_response.json() + assert body["connections"] == [] + assert body["global_connections"] == [ + { + "provider": "github", + "provider_display_name": "GitHub", + "connection_name": "default", + "status": "connected", + "auth_type": "oauth2", + "account_label": "GitHub Admin", + "api_url": None, + "source": "global", + } + ] + assert "access-default" not in str(body["global_connections"]) + assert detail_response.status_code == status.HTTP_403_FORBIDDEN + assert raw_response.status_code == status.HTTP_404_NOT_FOUND + + +def test_global_connection_resolves_for_explicit_default_and_proxy_routes(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection( + client, + admin_ctx.principal_id, + admin_ctx.vault_id, + "shared", + api_url="api.enterprise-github.test", + ) + + set_response = client.post( + "/api/connections/github/shared/global", + headers=_auth_header(tmp_path, "POST", "/api/connections/github/shared/global", handle=admin.handle), + ) + omitted_body = json.dumps({"provider": "github"}, separators=(",", ":"), sort_keys=True).encode("utf-8") + omitted_response = client.post( + "/api/credentials/resolve", + content=omitted_body, + headers={ + **_auth_header(tmp_path, "POST", "/api/credentials/resolve", body=omitted_body, handle=user.handle), + "Content-Type": "application/json", + }, + ) + explicit_body = json.dumps( + {"connection": "default", "provider": "github"}, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + explicit_response = client.post( + "/api/credentials/resolve", + content=explicit_body, + headers={ + **_auth_header(tmp_path, "POST", "/api/credentials/resolve", body=explicit_body, handle=user.handle), + "Content-Type": "application/json", + }, + ) + routes_response = client.get( + "/api/proxy/routes", + headers=_auth_header(tmp_path, "GET", "/api/proxy/routes", handle=user.handle), + ) + + assert set_response.status_code == status.HTTP_200_OK + assert omitted_response.status_code == status.HTTP_200_OK + assert omitted_response.json()["source"] == "global" + assert omitted_response.json()["connection"] == "shared" + assert omitted_response.json()["headers"] == {"Authorization": "Bearer access-shared"} + assert explicit_response.status_code == status.HTTP_200_OK + assert explicit_response.json()["source"] == "global" + assert explicit_response.json()["connection"] == "shared" + assert explicit_response.json()["headers"] == {"Authorization": "Bearer access-shared"} + assert routes_response.status_code == status.HTTP_200_OK + assert any( + route["provider"] == "github" + and route["connection"] == "default" + and route["api_url"] == "api.enterprise-github.test" + for route in routes_response.json()["routes"] + ) + + +def test_non_admin_cannot_make_connection_global(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=user.handle)) + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "default") + + response = client.post( + "/api/connections/github/default/global", + headers=_auth_header(tmp_path, "POST", "/api/connections/github/default/global", handle=user.handle), + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Admin role required" + + +def test_admin_can_remove_global_connection(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with create_server_test_client() as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "default") + assert ( + client.post( + "/api/connections/github/default/global", + headers=_auth_header(tmp_path, "POST", "/api/connections/github/default/global", handle=admin.handle), + ).status_code + == status.HTTP_200_OK + ) + + remove_response = client.delete( + "/api/connections/github/global", + headers=_auth_header(tmp_path, "DELETE", "/api/connections/github/global", handle=admin.handle), + ) + list_response = client.get( + "/api/connections", + headers=_auth_header(tmp_path, "GET", "/api/connections", handle=user.handle), + ) + + assert remove_response.status_code == status.HTTP_200_OK + assert remove_response.json() == {"status": "ok", "provider": "github", "deleted": True} + assert list_response.status_code == status.HTTP_200_OK + assert list_response.json()["global_connections"] == [] diff --git a/tests/server/test_store_global_connections.py b/tests/server/test_store_global_connections.py new file mode 100644 index 00000000..9839a04d --- /dev/null +++ b/tests/server/test_store_global_connections.py @@ -0,0 +1,278 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from authsome.server.schemas import GlobalProviderConnectionRecord +from authsome.server.store import ServerStore, create_server_store +from authsome.server.store import repositories as store_repositories + + +async def _create_owner(store: ServerStore) -> tuple[str, str]: + principal = await store.principals.create_by_email("admin@example.com") + vault = await store.vaults.create_default() + return principal.principal_id, vault.vault_id + + +@pytest.mark.asyncio +async def test_global_provider_connection_roundtrip(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + principal_id, vault_id = await _create_owner(store) + record = GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + created_by_identity="admin-ready-boldly-0001", + ) + + await store.global_provider_connections.upsert(record) + + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert loaded.provider == "github" + assert loaded.owner_principal_id == principal_id + assert loaded.owner_vault_id == vault_id + assert loaded.connection_name == "default" + assert loaded.created_by_identity == "admin-ready-boldly-0001" + + listed = await store.global_provider_connections.list_all() + assert [row.provider for row in listed] == ["github"] + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_upsert_replaces_provider_pointer(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + first_principal_id, first_vault_id = await _create_owner(store) + second_principal_id = (await store.principals.create_by_email("admin2@example.com")).principal_id + second_vault_id = (await store.vaults.create_default()).vault_id + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=first_principal_id, + owner_vault_id=first_vault_id, + connection_name="default", + ) + ) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=second_principal_id, + owner_vault_id=second_vault_id, + connection_name="team", + ) + ) + + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert loaded.owner_principal_id == second_principal_id + assert loaded.owner_vault_id == second_vault_id + assert loaded.connection_name == "team" + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_upsert_preserves_created_at_and_advances_updated_at( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = await create_server_store(home=tmp_path) + try: + first_principal_id, first_vault_id = await _create_owner(store) + second_principal_id = (await store.principals.create_by_email("admin2@example.com")).principal_id + second_vault_id = (await store.vaults.create_default()).vault_id + first_now = datetime(2025, 1, 2, 3, 4, 5, tzinfo=UTC) + second_now = first_now + timedelta(minutes=5) + caller_created_at = first_now - timedelta(days=30) + + monkeypatch.setattr(store_repositories, "utc_now", lambda: first_now) + inserted = await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=first_principal_id, + owner_vault_id=first_vault_id, + connection_name="default", + created_at=caller_created_at, + updated_at=caller_created_at, + ) + ) + + monkeypatch.setattr(store_repositories, "utc_now", lambda: second_now) + replaced = await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=second_principal_id, + owner_vault_id=second_vault_id, + connection_name="team", + ) + ) + + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert inserted.created_at == first_now + assert inserted.updated_at == first_now + assert loaded.created_at == first_now + assert replaced.created_at == first_now + assert replaced.updated_at == second_now + assert loaded.updated_at == second_now + assert loaded.created_at != caller_created_at + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_delete(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + principal_id, vault_id = await _create_owner(store) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + ) + ) + + deleted = await store.global_provider_connections.delete("github") + + assert deleted is True + assert await store.global_provider_connections.get("github") is None + assert await store.global_provider_connections.delete("github") is False + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_delete_if_target_removes_matching_pointer(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + principal_id, vault_id = await _create_owner(store) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + ) + ) + + deleted = await store.global_provider_connections.delete_if_target("github", vault_id, "default") + + assert deleted is True + assert await store.global_provider_connections.get("github") is None + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_delete_if_target_ignores_non_matching_pointer(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + principal_id, vault_id = await _create_owner(store) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + ) + ) + + deleted = await store.global_provider_connections.delete_if_target("github", vault_id, "team") + + assert deleted is False + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert loaded.connection_name == "default" + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_delete_if_target_does_not_remove_repointed_pointer(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + first_principal_id, first_vault_id = await _create_owner(store) + second_principal_id = (await store.principals.create_by_email("admin2@example.com")).principal_id + second_vault_id = (await store.vaults.create_default()).vault_id + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=first_principal_id, + owner_vault_id=first_vault_id, + connection_name="default", + ) + ) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=second_principal_id, + owner_vault_id=second_vault_id, + connection_name="team", + ) + ) + + deleted = await store.global_provider_connections.delete_if_target("github", first_vault_id, "default") + + assert deleted is False + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert loaded.owner_principal_id == second_principal_id + assert loaded.owner_vault_id == second_vault_id + assert loaded.connection_name == "team" + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_global_provider_connection_delete_if_target_respects_observed_updated_at( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = await create_server_store(home=tmp_path) + try: + principal_id, vault_id = await _create_owner(store) + first_now = datetime(2025, 1, 2, 3, 4, 5, tzinfo=UTC) + second_now = first_now + timedelta(minutes=5) + + monkeypatch.setattr(store_repositories, "utc_now", lambda: first_now) + observed = await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + ) + ) + + monkeypatch.setattr(store_repositories, "utc_now", lambda: second_now) + await store.global_provider_connections.upsert( + GlobalProviderConnectionRecord( + provider="github", + owner_principal_id=principal_id, + owner_vault_id=vault_id, + connection_name="default", + ) + ) + + deleted = await store.global_provider_connections.delete_if_target( + "github", + vault_id, + "default", + updated_at=observed.updated_at, + ) + + assert deleted is False + loaded = await store.global_provider_connections.get("github") + assert loaded is not None + assert loaded.owner_vault_id == vault_id + assert loaded.connection_name == "default" + assert loaded.updated_at == second_now + finally: + await store.close() From 0975ab82e04f9fe117cf2e73d976488d436da004 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 13:57:45 +0530 Subject: [PATCH 2/4] feat: expose global connection CLI controls --- src/authsome/cli/client.py | 6 +++ src/authsome/cli/commands/connections.py | 21 ++++++++ src/authsome/cli/commands/provider.py | 9 ++++ tests/cli/conftest.py | 1 + tests/cli/test_client_signing.py | 42 +++++++++++++++ tests/cli/test_connections_global.py | 31 +++++++++++ tests/cli/test_list.py | 66 ++++++++++++++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 tests/cli/test_connections_global.py diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index c2894851..8b09fb0f 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -235,6 +235,12 @@ async def revoke(self, provider: str) -> None: async def set_default_connection(self, provider: str, connection_name: str) -> None: await self._post(f"{API_PREFIX}/connections/{provider}/{connection_name}/default") + async def set_global_connection(self, provider: str, connection_name: str) -> dict[str, Any]: + return await self._post(f"{API_PREFIX}/connections/{provider}/{connection_name}/global") + + async def unset_global_connection(self, provider: str) -> dict[str, Any]: + return await self._delete(f"{API_PREFIX}/connections/{provider}/global") + async def get_provider(self, provider: str) -> dict[str, Any]: return await self._get(f"{API_PREFIX}/providers/{provider}") diff --git a/src/authsome/cli/commands/connections.py b/src/authsome/cli/commands/connections.py index 049fcfbc..c4e33bca 100644 --- a/src/authsome/cli/commands/connections.py +++ b/src/authsome/cli/commands/connections.py @@ -26,6 +26,27 @@ async def set_default_connection(ctx_obj: ContextObj, provider: str, connection: ctx_obj.print_json({"status": "ok", "provider": provider, "default_connection": connection}) +@connections.command(name="set-global") +@click.argument("provider") +@click.argument("connection") +@auth_command +async def set_global_connection(ctx_obj: ContextObj, provider: str, connection: str) -> None: + """Make CONNECTION the global fallback for PROVIDER.""" + actx = await ctx_obj.initialize() + result = await actx.runtime_client.set_global_connection(provider, connection) + ctx_obj.print_json(result) + + +@connections.command(name="unset-global") +@click.argument("provider") +@auth_command +async def unset_global_connection(ctx_obj: ContextObj, provider: str) -> None: + """Remove PROVIDER's global fallback connection.""" + actx = await ctx_obj.initialize() + result = await actx.runtime_client.unset_global_connection(provider) + ctx_obj.print_json(result) + + @connections.command(name="inspect") @click.argument("provider") @click.option("--connection", default="default", metavar="NAME", help="Connection name.") diff --git a/src/authsome/cli/commands/provider.py b/src/authsome/cli/commands/provider.py index f4b3de04..910a761b 100644 --- a/src/authsome/cli/commands/provider.py +++ b/src/authsome/cli/commands/provider.py @@ -26,11 +26,15 @@ async def list_cmd(ctx_obj: ContextObj) -> None: actx = await ctx_obj.initialize() data = await actx.runtime_client.list_connections() raw_list = data["connections"] + global_list = data.get("global_connections", []) by_source = data["by_source"] connected: dict[str, list[dict]] = {} for provider_group in raw_list: connected[provider_group["name"]] = provider_group["connections"] + global_connected: dict[str, list[dict]] = {} + for conn in global_list: + global_connected.setdefault(conn["provider"], []).append(conn) def build_provider_entry(provider_data, source: str) -> dict: provider_definition = ProviderDefinition.model_validate(provider_data) @@ -54,6 +58,7 @@ def build_provider_entry(provider_data, source: str) -> dict: "auth_type": provider_definition.auth_type.value, "source": source, "connections": connections_out, + "global_connections": global_connected.get(provider_definition.name, []), } bundled_out = [build_provider_entry(p, "bundled") for p in by_source["bundled"]] @@ -94,11 +99,15 @@ async def inspect_provider(ctx_obj: ContextObj, provider: str) -> None: definition_dict = await actx.runtime_client.get_provider(provider) data = definition_dict data["connections"] = [] + data["global_connections"] = [] connections_data = await actx.runtime_client.list_connections() for provider_group in connections_data["connections"]: if provider_group["name"] == provider: data["connections"] = provider_group["connections"] break + data["global_connections"] = [ + conn for conn in connections_data.get("global_connections", []) if conn["provider"] == provider + ] data.pop("schema_version", None) ctx_obj.print_json(data) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9b245880..2cd25f6d 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -50,6 +50,7 @@ def mock_client() -> AsyncMock: # Default empty connections client.list_connections.return_value = { "connections": [], + "global_connections": [], "by_source": {"bundled": [], "custom": []}, } client.ensure_identity_ready.return_value = SimpleNamespace( diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index a81bbfe8..c724fb7a 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -85,6 +85,48 @@ def fake_request(method, url, data=None, headers=None, timeout=None): assert captured["data"] == json.dumps({}, separators=(",", ":"), sort_keys=True).encode("utf-8") +@pytest.mark.asyncio +async def test_set_global_connection_request_is_signed(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + captured: dict = {} + + def fake_request(method, url, data=None, headers=None, timeout=None): + captured.update({"method": method, "url": url, "data": data, "headers": headers, "timeout": timeout}) + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"status": "ok", "provider": "github", "connection_name": "work"} + return response + + _patch_httpx_request(monkeypatch, fake_request) + + await AuthsomeApiClient("http://127.0.0.1:7998").set_global_connection("github", "work") + + assert captured["method"] == "POST" + assert captured["url"].endswith("/api/connections/github/work/global") + assert captured["headers"]["Authorization"].startswith("PoP ") + + +@pytest.mark.asyncio +async def test_unset_global_connection_request_is_signed(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + captured: dict = {} + + def fake_request(method, url, data=None, headers=None, timeout=None): + captured.update({"method": method, "url": url, "data": data, "headers": headers, "timeout": timeout}) + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"status": "ok", "provider": "github", "deleted": True} + return response + + _patch_httpx_request(monkeypatch, fake_request) + + await AuthsomeApiClient("http://127.0.0.1:7998").unset_global_connection("github") + + assert captured["method"] == "DELETE" + assert captured["url"].endswith("/api/connections/github/global") + assert captured["headers"]["Authorization"].startswith("PoP ") + + @pytest.mark.asyncio async def test_proxy_routes_request_is_signed(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) diff --git a/tests/cli/test_connections_global.py b/tests/cli/test_connections_global.py new file mode 100644 index 00000000..41cf5960 --- /dev/null +++ b/tests/cli/test_connections_global.py @@ -0,0 +1,31 @@ +import json + +from authsome.cli.main import cli + + +def test_connections_set_global_outputs_server_response(runner, mock_client) -> None: + mock_client.set_global_connection.return_value = { + "status": "ok", + "provider": "github", + "connection_name": "default", + } + + result = runner.invoke(cli, ["--log-file", "", "connections", "set-global", "github", "default"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output) == {"status": "ok", "provider": "github", "connection_name": "default", "v": 1} + mock_client.set_global_connection.assert_awaited_once_with("github", "default") + + +def test_connections_unset_global_outputs_server_response(runner, mock_client) -> None: + mock_client.unset_global_connection.return_value = { + "status": "ok", + "provider": "github", + "deleted": True, + } + + result = runner.invoke(cli, ["--log-file", "", "connections", "unset-global", "github"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output) == {"status": "ok", "provider": "github", "deleted": True, "v": 1} + mock_client.unset_global_connection.assert_awaited_once_with("github") diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py index 11db85a0..3cb22203 100644 --- a/tests/cli/test_list.py +++ b/tests/cli/test_list.py @@ -9,12 +9,15 @@ def _make_list_response( bundled: list[dict] | None = None, custom: list[dict] | None = None, connections: list[dict] | None = None, + global_connections: list[dict] | None = None, ) -> dict: bundled = bundled or [] custom = custom or [] connections = connections or [] + global_connections = global_connections or [] return { "connections": connections, + "global_connections": global_connections, "by_source": {"bundled": bundled, "custom": custom}, } @@ -62,6 +65,69 @@ def test_json_output_shape(self, runner, mock_client) -> None: assert data["bundled"][0]["name"] == "openai" assert data["bundled"][0]["connections"][0]["status"] == "connected" + def test_json_output_includes_global_connections(self, runner, mock_client) -> None: + provider_def = { + "name": "openai", + "display_name": "OpenAI", + "auth_type": "api_key", + "flow": "api_key", + "schema_version": 1, + "api_key": {"header_name": "Authorization", "header_prefix": "Bearer"}, + } + mock_client.list_connections.return_value = _make_list_response( + bundled=[provider_def], + global_connections=[ + { + "provider": "openai", + "provider_display_name": "OpenAI", + "connection_name": "default", + "status": "connected", + "auth_type": "api_key", + "account_label": "Team OpenAI", + "source": "global", + } + ], + ) + + result = runner.invoke(cli, ["--log-file", "", "provider", "list"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["bundled"][0]["connections"] == [] + assert data["bundled"][0]["global_connections"][0]["source"] == "global" + + def test_provider_inspect_includes_matching_global_connections(self, runner, mock_client) -> None: + provider_def = { + "name": "openai", + "display_name": "OpenAI", + "auth_type": "api_key", + "flow": "api_key", + "schema_version": 1, + "api_key": {"header_name": "Authorization", "header_prefix": "Bearer"}, + } + mock_client.get_provider.return_value = provider_def.copy() + mock_client.list_connections.return_value = _make_list_response( + bundled=[provider_def], + global_connections=[ + { + "provider": "openai", + "provider_display_name": "OpenAI", + "connection_name": "default", + "status": "connected", + "auth_type": "api_key", + "account_label": "Team OpenAI", + "source": "global", + } + ], + ) + + result = runner.invoke(cli, ["--log-file", "", "provider", "inspect", "openai"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["connections"] == [] + assert data["global_connections"][0]["connection_name"] == "default" + def test_no_color_flag_does_not_change_json(self, runner, mock_client) -> None: provider_def = { "name": "openai", From b21f5b11b21daa70fc7cd2146bbe6a98d6ce19b6 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 13:57:53 +0530 Subject: [PATCH 3/4] feat: show global connections in dashboard --- ui/src/components/authsome-dashboard.tsx | 149 ++++++++++++++++++++++- ui/src/lib/authsome-api.ts | 73 ++++++++++- 2 files changed, 213 insertions(+), 9 deletions(-) diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index 23525f18..d09736e7 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -10,6 +10,7 @@ import { ClipboardList, Database, GitBranch, + Globe2, KeyRound, LifeBuoy, Link2, @@ -32,6 +33,7 @@ import { ApiError, ConnectionDetail, DashboardData, + GlobalConnectionRow, PrincipalRow, ProviderDetail, ProviderView, @@ -46,6 +48,8 @@ import { fetchSessionInput, logoutConnection, revokeProvider, + setGlobalConnection, + unsetGlobalConnection, updateProviderConfiguration, } from "@/lib/authsome-api"; import { Badge } from "@/components/ui/badge"; @@ -1029,6 +1033,7 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr {provider.connectionCount} connection{provider.connectionCount !== 1 ? "s" : ""} ) : null} + {provider.globalConnectionCount ? Global : null} {provider.requiresNamedLogin ? ( + ); +} + function AgentsView({ data }: { data: DashboardData }) { return (
@@ -1845,6 +1953,8 @@ function ConnectionActions({ }) { const [open, setOpen] = useState(false); const [working, setWorking] = useState(false); + const [globalWorking, setGlobalWorking] = useState(false); + const [globalMessage, setGlobalMessage] = useState(""); async function logout() { setWorking(true); @@ -1857,6 +1967,20 @@ function ConnectionActions({ } } + async function makeGlobal() { + setGlobalWorking(true); + setGlobalMessage(""); + try { + await setGlobalConnection(data.provider, data.connection_name); + setGlobalMessage("Global connection updated."); + onRefresh(); + } catch (error) { + setGlobalMessage(error instanceof Error ? error.message : "Global connection could not be updated."); + } finally { + setGlobalWorking(false); + } + } + return ( @@ -1873,6 +1997,13 @@ function ConnectionActions({ ) : null} + {data.can_set_global ? ( + + ) : null} + {globalMessage ?
{globalMessage}
: null}
diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index 386f5273..ab130baa 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -18,6 +18,7 @@ export type ProviderView = { status: "available" | "connected" | "reauth" | string; scopeCount: number; connectionCount: number; + globalConnectionCount: number; requiresNamedLogin: boolean; }; @@ -29,6 +30,10 @@ export type ConnectionRow = { authTypeLabel: string; }; +export type GlobalConnectionRow = ConnectionRow & { + accountLabel: string | null; +}; + export type IdentityRow = { handle: string; isActive: boolean; @@ -59,6 +64,7 @@ export type DashboardData = { providers: ProviderView[]; connectedProviders: ProviderView[]; connections: ConnectionRow[]; + globalConnections: GlobalConnectionRow[]; identities: IdentityRow[]; vault: { vaultId: string | null; @@ -97,6 +103,17 @@ type ConnectionSummary = { expires_at?: string | null; }; +type GlobalConnectionSummary = { + provider: string; + provider_display_name: string; + connection_name: string; + status: string; + auth_type: string; + account_label: string | null; + api_url?: string | null; + source: "global"; +}; + export type ProviderResponse = { name: string; display_name?: string; @@ -185,6 +202,7 @@ export type ConnectionDetail = { credentials: Record; }; can_set_default: boolean; + can_set_global: boolean; }; type ConnectionsResponse = { @@ -192,6 +210,7 @@ type ConnectionsResponse = { name: string; connections: ConnectionSummary[]; }>; + global_connections: GlobalConnectionSummary[]; by_source: Record; }; @@ -318,11 +337,11 @@ function providerApiUrl(provider: ProviderResponse): string { return provider.api_url || provider.oauth?.base_url || provider.name; } -function providerStatus(connections: ConnectionSummary[]): ProviderView["status"] { - if (!connections.length) { +function providerStatus(connections: ConnectionSummary[], globalConnections: GlobalConnectionSummary[]): ProviderView["status"] { + if (!connections.length && !globalConnections.length) { return "available"; } - return connections.some((connection) => ["error", "expired"].includes(connection.status || "")) + return [...connections, ...globalConnections].some((connection) => ["error", "expired"].includes(connection.status || "")) ? "reauth" : "connected"; } @@ -331,6 +350,7 @@ function providerView( provider: ProviderResponse, source: string, connections: ConnectionSummary[], + globalConnections: GlobalConnectionSummary[], ): ProviderView { const displayName = provider.display_name || provider.name; return { @@ -343,17 +363,26 @@ function providerView( source, logo: provider.logo || null, logoInitial: (displayName[0] || "?").toUpperCase(), - status: providerStatus(connections), + status: providerStatus(connections, globalConnections), scopeCount: connections[0]?.scopes?.length || 0, - connectionCount: connections.length, + connectionCount: connections.length + globalConnections.length, + globalConnectionCount: globalConnections.length, requiresNamedLogin: connections.some((connection) => connection.connection_name === "default"), }; } function buildProviders(data: ConnectionsResponse): ProviderView[] { const connectionMap = new Map(data.connections.map((group) => [group.name, group.connections])); + const globalConnectionMap = new Map(); + for (const connection of data.global_connections || []) { + const entries = globalConnectionMap.get(connection.provider) || []; + entries.push(connection); + globalConnectionMap.set(connection.provider, entries); + } return Object.entries(data.by_source).flatMap(([source, providers]) => - providers.map((provider) => providerView(provider, source, connectionMap.get(provider.name) || [])), + providers.map((provider) => + providerView(provider, source, connectionMap.get(provider.name) || [], globalConnectionMap.get(provider.name) || []), + ), ); } @@ -373,6 +402,19 @@ function buildConnectionRows(data: ConnectionsResponse, providers: ProviderView[ .sort((a, b) => `${a.providerDisplayName}:${a.connectionName}`.localeCompare(`${b.providerDisplayName}:${b.connectionName}`)); } +function buildGlobalConnectionRows(data: ConnectionsResponse): GlobalConnectionRow[] { + return (data.global_connections || []) + .map((connection) => ({ + providerName: connection.provider, + providerDisplayName: connection.provider_display_name, + connectionName: connection.connection_name, + status: connection.status || "unknown", + authTypeLabel: authTypeLabel(connection.auth_type), + accountLabel: connection.account_label, + })) + .sort((a, b) => `${a.providerDisplayName}:${a.connectionName}`.localeCompare(`${b.providerDisplayName}:${b.connectionName}`)); +} + function formatRelative(value: string | null | undefined): string | null { if (!value) { return null; @@ -457,6 +499,7 @@ export async function fetchDashboard(): Promise { const audit = isAdmin ? await requestJson("/api/audit/events?limit=100") : { entries: [] }; const providers = buildProviders(connectionsData); const connections = buildConnectionRows(connectionsData, providers); + const globalConnections = buildGlobalConnectionRows(connectionsData); const connectedProviders = providers.filter((provider) => provider.status !== "available"); const activeIdentity = whoami.identity || whoami.active_identity || null; const identityHandles = new Set(identitiesData.identities.map((identity) => identity.handle)); @@ -483,6 +526,7 @@ export async function fetchDashboard(): Promise { providers, connectedProviders: connectedProviders.slice(0, 6), connections, + globalConnections, identities: Array.from(identityHandles, (handle) => ({ handle, isActive: handle === activeIdentity })), vault: { vaultId: whoami.vault_id || null, @@ -555,6 +599,23 @@ export async function logoutConnection( }); } +export async function setGlobalConnection( + provider: string, + connection: string, +): Promise<{ status: string; provider: string; connection_name: string }> { + return sendJson(`/api/connections/${encodeURIComponent(provider)}/${encodeURIComponent(connection)}/global`, { + method: "POST", + body: "{}", + }); +} + +export async function unsetGlobalConnection(provider: string): Promise<{ status: string; provider: string; deleted: boolean }> { + return sendJson(`/api/connections/${encodeURIComponent(provider)}/global`, { + method: "DELETE", + body: "{}", + }); +} + export async function revokeProvider(provider: string): Promise<{ status: string; provider?: string }> { return sendJson(`/api/connections/${encodeURIComponent(provider)}/revoke`, { method: "POST", From e6e51fddf3134c39d385afcb4c53b24616d4fcac Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:14:29 +0530 Subject: [PATCH 4/4] fix: pass global connection props to connections page --- ui/src/app/(authenticated)/connections/page.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/src/app/(authenticated)/connections/page.tsx b/ui/src/app/(authenticated)/connections/page.tsx index a731db39..cab274fa 100644 --- a/ui/src/app/(authenticated)/connections/page.tsx +++ b/ui/src/app/(authenticated)/connections/page.tsx @@ -6,7 +6,14 @@ import { ConnectionsView } from "@/components/authsome-dashboard"; import { fetchDashboard } from "@/lib/authsome-api"; export default function ConnectionsPage() { - const { data } = useSWR("authsome-dashboard", fetchDashboard); + const { data, mutate } = useSWR("authsome-dashboard", fetchDashboard); if (!data) return null; - return ; + return ( + void mutate()} + /> + ); }