File size: 12,930 Bytes
4b9d59b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""
Local-Only Mode Guard for Privacy-Focused Operation

This module implements local-only mode enforcement for Personal Edition.
When enabled, blocks all external API calls to cloud services while allowing
local network services (Sonos, Hue, Home Assistant, FFmpeg) to function.

Features:
- Singleton pattern for global state management
- Environment-based configuration (ATOM_LOCAL_ONLY)
- Decorator for function-level enforcement
- Clear error messages with suggestions
- Audit logging for blocked requests

Blocked Services (cloud-based):
- Spotify, Notion, OpenAI, Anthropic, DeepSeek
- Tavily, Brave Search, Slack, Gmail
- Any OAuth-based external service

Allowed Services (local):
- Sonos (local network)
- Philips Hue (local network)
- Home Assistant (local network)
- FFmpeg (local processing)
- mDNS/local network discovery

Usage:
    from core.security import require_local_allowed

    @require_local_allowed("spotify")
    async def get_current_track(user_id: str):
        # Raises LocalOnlyModeError if local-only mode enabled
        pass
"""

import functools
import logging
import os
from typing import Callable, List, Optional, Set
from fastapi import HTTPException, status

from core.structured_logger import get_logger

logger = get_logger(__name__)


# ============================================================================
# Exception Classes
# ============================================================================

class LocalOnlyModeError(HTTPException):
    """
    Raised when external service is blocked in local-only mode.

    Provides clear error message with suggestion to disable local-only mode
    or use alternative local services.
    """

    def __init__(
        self,
        service: str,
        reason: Optional[str] = None,
        suggested_alternatives: Optional[List[str]] = None
    ):
        """
        Initialize local-only mode error.

        Args:
            service: Name of blocked service (e.g., "spotify", "notion")
            reason: Optional reason for blocking (e.g., "OAuth requires cloud")
            suggested_alternatives: Optional list of local alternatives
        """
        self.service = service
        self.reason = reason
        self.suggested_alternatives = suggested_alternatives or []

        message = f"Service '{service}' is blocked in local-only mode"
        if reason:
            message += f": {reason}"

        if suggested_alternatives:
            message += f"\n\nLocal alternatives: {', '.join(suggested_alternatives)}"

        message += ".\n\nDisable local-only mode (set ATOM_LOCAL_ONLY=false) or use local services only."

        # Call parent with HTTP 403 Forbidden
        super().__init__(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=message
        )


# ============================================================================
# Local-Only Guard Service
# ============================================================================

class LocalOnlyGuard:
    """
    Singleton service for enforcing local-only mode.

    Checks environment variable ATOM_LOCAL_ONLY and caches the value
    to avoid repeated environment lookups. Provides methods to check
    if external requests are allowed and blocks them if not.

    Thread-safe: Uses module-level singleton instance
    """

    _instance: Optional['LocalOnlyGuard'] = None
    _enabled: Optional[bool] = None

    # Cloud-based services blocked in local-only mode
    BLOCKED_SERVICES: Set[str] = {
        # Music/Media (cloud)
        "spotify",
        "apple_music",
        "youtube_music",

        # Productivity (cloud)
        "notion",
        "trello",
        "asana",
        "slack",
        "gmail",
        "google_calendar",

        # AI Providers (cloud)
        "openai",
        "anthropic",
        "deepseek",
        "cohere",
        "gemini",

        # Search (cloud)
        "tavily",
        "brave_search",
        "google_search",

        # Social (cloud)
        "twitter",
        "facebook",
        "linkedin",

        # Generic cloud APIs
        "oauth",
        "rest_api",
        "graphql",
    }

    # Local network services allowed in local-only mode
    LOCAL_ALLOWED_SERVICES: Set[str] = {
        # Media (local network)
        "sonos",           # Sonos speakers on local network
        "chromecast",      # Google Cast on local network
        "airplay",         # Apple AirPlay on local network

        # Smart Home (local network)
        "hue",             # Philips Hue Bridge
        "home_assistant",  # Home Assistant instance
        "homekit",         # Apple HomeKit
        "zigbee",          # Zigbee2MQTT
        "zwave",           # Z-Wave JS

        # File Processing (local)
        "ffmpeg",          # FFmpeg media processing
        "image_magick",    # ImageMagick processing
        "pandoc",          # Document conversion

        # Discovery (local network)
        "mdns",            # mDNS/Bonjour discovery
        "upnp",            # UPnP discovery
        "ssdp",            # Simple Service Discovery

        # Generic local
        "localhost",
        "127.0.0.1",
        "local_network",
        "lan",
    }

    def __new__(cls) -> 'LocalOnlyGuard':
        """Implement singleton pattern."""
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        """Initialize guard (only runs once due to singleton)."""
        if self._enabled is None:
            # Read from environment on first access
            self._enabled = os.getenv("ATOM_LOCAL_ONLY", "false").lower() == "true"

            logger.info(
                "LocalOnlyGuard initialized",
                extra={
                    "local_only_enabled": self._enabled,
                    "blocked_services": len(self.BLOCKED_SERVICES),
                    "local_allowed_services": len(self.LOCAL_ALLOWED_SERVICES)
                }
            )

    @classmethod
    def reset_cache(cls):
        """
        Reset cached configuration (mainly for testing).

        Forces re-read of environment variable on next access.
        """
        cls._enabled = None
        logger.debug("LocalOnlyGuard cache reset")

    def is_local_only_enabled(self) -> bool:
        """
        Check if local-only mode is currently enabled.

        Returns:
            True if local-only mode is active, False otherwise
        """
        return self._enabled

    def allow_external_request(
        self,
        service: str,
        reason: Optional[str] = None
    ) -> bool:
        """
        Check if external service request is allowed.

        Returns True if local-only mode is disabled.
        Returns False (raises LocalOnlyModeError) if local-only mode is enabled.

        Args:
            service: Name of service being accessed (e.g., "spotify")
            reason: Optional reason for access (for error message)

        Returns:
            True if request is allowed

        Raises:
            LocalOnlyModeError: If service is blocked in local-only mode
        """
        # If local-only mode is disabled, allow everything
        if not self._enabled:
            return True

        # Check if service is explicitly blocked
        service_lower = service.lower()
        if service_lower in self.BLOCKED_SERVICES:
            # Log blocked request for audit
            logger.warning(
                "Local-only mode: blocked external service request",
                extra={
                    "service": service,
                    "reason": reason,
                    "local_only_enabled": True
                }
            )

            # Suggest local alternatives if available
            alternatives = self._get_local_alternatives(service_lower)
            raise LocalOnlyModeError(
                service=service,
                reason=reason,
                suggested_alternatives=alternatives
            )

        # Service is either local-allowed or unknown
        # Unknown services are allowed by default (fail-open for local services)
        return True

    def get_blocked_services(self) -> List[str]:
        """
        Get list of services blocked in local-only mode.

        Returns:
            Sorted list of blocked service names
        """
        return sorted(self.BLOCKED_SERVICES)

    def get_local_allowed_services(self) -> List[str]:
        """
        Get list of services that work locally (no external API).

        Returns:
            Sorted list of local-allowed service names
        """
        return sorted(self.LOCAL_ALLOWED_SERVICES)

    def is_service_blocked(self, service: str) -> bool:
        """
        Check if a specific service is blocked in local-only mode.

        Args:
            service: Service name to check

        Returns:
            True if service would be blocked, False otherwise
        """
        service_lower = service.lower()
        return service_lower in self.BLOCKED_SERVICES

    def is_service_local_allowed(self, service: str) -> bool:
        """
        Check if a service is explicitly allowed in local-only mode.

        Args:
            service: Service name to check

        Returns:
            True if service is explicitly local-allowed, False otherwise
        """
        service_lower = service.lower()
        return service_lower in self.LOCAL_ALLOWED_SERVICES

    def _get_local_alternatives(self, blocked_service: str) -> List[str]:
        """
        Get local alternatives for blocked cloud service.

        Args:
            blocked_service: Name of blocked service

        Returns:
            List of local alternative service names
        """
        alternatives_map = {
            "spotify": ["sonos", "airplay"],
            "apple_music": ["sonos", "airplay"],
            "youtube_music": ["sonos"],
            "notion": ["local markdown files"],
            "trello": ["local kanban boards"],
            "asana": ["local task management"],
            "slack": ["local messaging"],
            "gmail": ["local email client"],
            "google_calendar": ["local calendar"],
            "openai": ["local LLM (Ollama)"],
            "anthropic": ["local LLM (Ollama)"],
            "deepseek": ["local LLM (Ollama)"],
            "tavily": ["local search"],
            "brave_search": ["local search"],
        }

        return alternatives_map.get(blocked_service.lower(), [])


# ============================================================================
# Decorator for Function-Level Enforcement
# ============================================================================

def require_local_allowed(service_name: str):
    """
    Decorator to enforce local-only mode at function level.

    Raises LocalOnlyModeError before function execution if local-only mode
    is enabled and the service is blocked.

    Args:
        service_name: Name of service being accessed (e.g., "spotify")

    Usage:
        @require_local_allowed("spotify")
        async def get_current_track(user_id: str):
            # This will raise LocalOnlyModeError if local-only is enabled
            pass

    Returns:
        Decorated function that checks local-only mode before execution
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        async def async_wrapper(*args, **kwargs):
            guard = LocalOnlyGuard()
            guard.allow_external_request(
                service=service_name,
                reason=f"Function '{func.__name__}' requires {service_name} access"
            )
            return await func(*args, **kwargs)

        @functools.wraps(func)
        def sync_wrapper(*args, **kwargs):
            guard = LocalOnlyGuard()
            guard.allow_external_request(
                service=service_name,
                reason=f"Function '{func.__name__}' requires {service_name} access"
            )
            return func(*args, **kwargs)

        # Return appropriate wrapper based on function type
        if asyncio.iscoroutinefunction(func):
            return async_wrapper
        else:
            return sync_wrapper

    return decorator


# Import asyncio for coroutine function detection
import asyncio


# ============================================================================
# Module-Level Singleton Instance
# ============================================================================

# Global instance for convenient access
_local_only_guard_instance: Optional[LocalOnlyGuard] = None


def get_local_only_guard() -> LocalOnlyGuard:
    """
    Get singleton LocalOnlyGuard instance.

    Returns:
        LocalOnlyGuard singleton instance
    """
    global _local_only_guard_instance
    if _local_only_guard_instance is None:
        _local_only_guard_instance = LocalOnlyGuard()
    return _local_only_guard_instance