pactown 0.1.4__py3-none-any.whl → 0.1.47__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pactown/security.py ADDED
@@ -0,0 +1,682 @@
1
+ """
2
+ Security module for pactown.
3
+
4
+ Provides rate limiting, resource protection, user profiles with service limits,
5
+ throttling under load, and anomaly logging for admin monitoring.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, UTC
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from threading import Lock
17
+ from typing import Callable, Dict, List, Optional, Any
18
+ import logging
19
+
20
+
21
+ # Configure logging for anomalies
22
+ logging.basicConfig(level=logging.INFO)
23
+ anomaly_logger = logging.getLogger("pactown.security.anomaly")
24
+
25
+
26
+ class AnomalyType(str, Enum):
27
+ """Types of security anomalies."""
28
+ RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
29
+ CONCURRENT_LIMIT_EXCEEDED = "concurrent_limit_exceeded"
30
+ MEMORY_LIMIT_EXCEEDED = "memory_limit_exceeded"
31
+ CPU_LIMIT_EXCEEDED = "cpu_limit_exceeded"
32
+ SERVER_OVERLOADED = "server_overloaded"
33
+ SUSPICIOUS_PATTERN = "suspicious_pattern"
34
+ UNAUTHORIZED_ACCESS = "unauthorized_access"
35
+ RESOURCE_EXHAUSTION = "resource_exhaustion"
36
+ RAPID_RESTART = "rapid_restart"
37
+ PORT_SCAN_DETECTED = "port_scan_detected"
38
+
39
+
40
+ class UserTier(str, Enum):
41
+ """User tier levels with different resource limits."""
42
+ FREE = "free"
43
+ BASIC = "basic"
44
+ PRO = "pro"
45
+ ENTERPRISE = "enterprise"
46
+ ADMIN = "admin"
47
+
48
+
49
+ @dataclass
50
+ class UserProfile:
51
+ """User profile with resource limits and permissions."""
52
+ user_id: str
53
+ tier: UserTier = UserTier.FREE
54
+ max_concurrent_services: int = 2
55
+ max_memory_mb: int = 512
56
+ max_cpu_percent: int = 50
57
+ max_requests_per_minute: int = 30
58
+ max_services_per_hour: int = 10
59
+ allowed_ports: Optional[List[int]] = None # None = any port in range
60
+ blocked: bool = False
61
+ reason: Optional[str] = None
62
+
63
+ @classmethod
64
+ def from_tier(cls, user_id: str, tier: UserTier) -> "UserProfile":
65
+ """Create profile with tier-based defaults."""
66
+ tier_limits = {
67
+ UserTier.FREE: {
68
+ "max_concurrent_services": 2,
69
+ "max_memory_mb": 256,
70
+ "max_cpu_percent": 25,
71
+ "max_requests_per_minute": 20,
72
+ "max_services_per_hour": 5,
73
+ },
74
+ UserTier.BASIC: {
75
+ "max_concurrent_services": 5,
76
+ "max_memory_mb": 512,
77
+ "max_cpu_percent": 50,
78
+ "max_requests_per_minute": 60,
79
+ "max_services_per_hour": 20,
80
+ },
81
+ UserTier.PRO: {
82
+ "max_concurrent_services": 10,
83
+ "max_memory_mb": 2048,
84
+ "max_cpu_percent": 80,
85
+ "max_requests_per_minute": 120,
86
+ "max_services_per_hour": 50,
87
+ },
88
+ UserTier.ENTERPRISE: {
89
+ "max_concurrent_services": 50,
90
+ "max_memory_mb": 8192,
91
+ "max_cpu_percent": 100,
92
+ "max_requests_per_minute": 500,
93
+ "max_services_per_hour": 200,
94
+ },
95
+ UserTier.ADMIN: {
96
+ "max_concurrent_services": 100,
97
+ "max_memory_mb": 16384,
98
+ "max_cpu_percent": 100,
99
+ "max_requests_per_minute": 1000,
100
+ "max_services_per_hour": 1000,
101
+ },
102
+ }
103
+ limits = tier_limits.get(tier, tier_limits[UserTier.FREE])
104
+ return cls(user_id=user_id, tier=tier, **limits)
105
+
106
+ def to_dict(self) -> dict:
107
+ """Convert to dictionary for API/JSON."""
108
+ return {
109
+ "user_id": self.user_id,
110
+ "tier": self.tier.value,
111
+ "max_concurrent_services": self.max_concurrent_services,
112
+ "max_memory_mb": self.max_memory_mb,
113
+ "max_cpu_percent": self.max_cpu_percent,
114
+ "max_requests_per_minute": self.max_requests_per_minute,
115
+ "max_services_per_hour": self.max_services_per_hour,
116
+ "allowed_ports": self.allowed_ports,
117
+ "blocked": self.blocked,
118
+ "reason": self.reason,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict) -> "UserProfile":
123
+ """Create from dictionary."""
124
+ tier = UserTier(data.get("tier", "free"))
125
+ return cls(
126
+ user_id=data.get("user_id", "unknown"),
127
+ tier=tier,
128
+ max_concurrent_services=data.get("max_concurrent_services", 2),
129
+ max_memory_mb=data.get("max_memory_mb", 512),
130
+ max_cpu_percent=data.get("max_cpu_percent", 50),
131
+ max_requests_per_minute=data.get("max_requests_per_minute", 30),
132
+ max_services_per_hour=data.get("max_services_per_hour", 10),
133
+ allowed_ports=data.get("allowed_ports"),
134
+ blocked=data.get("blocked", False),
135
+ reason=data.get("reason"),
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class AnomalyEvent:
141
+ """Record of a security anomaly."""
142
+ timestamp: datetime
143
+ anomaly_type: AnomalyType
144
+ user_id: Optional[str]
145
+ service_id: Optional[str]
146
+ details: str
147
+ severity: str # "low", "medium", "high", "critical"
148
+ metadata: Dict[str, Any] = field(default_factory=dict)
149
+
150
+ def to_dict(self) -> dict:
151
+ return {
152
+ "timestamp": self.timestamp.isoformat(),
153
+ "anomaly_type": self.anomaly_type.value,
154
+ "user_id": self.user_id,
155
+ "service_id": self.service_id,
156
+ "details": self.details,
157
+ "severity": self.severity,
158
+ "metadata": self.metadata,
159
+ }
160
+
161
+ def to_log_line(self) -> str:
162
+ return (
163
+ f"[{self.severity.upper()}] {self.anomaly_type.value} | "
164
+ f"user={self.user_id} service={self.service_id} | {self.details}"
165
+ )
166
+
167
+
168
+ class AnomalyLogger:
169
+ """Logs security anomalies for admin review."""
170
+
171
+ def __init__(
172
+ self,
173
+ log_path: Optional[Path] = None,
174
+ max_events: int = 10000,
175
+ on_anomaly: Optional[Callable[[AnomalyEvent], None]] = None,
176
+ ):
177
+ self.log_path = log_path or Path("/tmp/pactown-anomalies.jsonl")
178
+ self.max_events = max_events
179
+ self.on_anomaly = on_anomaly
180
+ self._events: List[AnomalyEvent] = []
181
+ self._lock = Lock()
182
+
183
+ def log(
184
+ self,
185
+ anomaly_type: AnomalyType,
186
+ details: str,
187
+ user_id: Optional[str] = None,
188
+ service_id: Optional[str] = None,
189
+ severity: str = "medium",
190
+ metadata: Optional[Dict[str, Any]] = None,
191
+ ) -> AnomalyEvent:
192
+ """Log an anomaly event."""
193
+ event = AnomalyEvent(
194
+ timestamp=datetime.now(UTC),
195
+ anomaly_type=anomaly_type,
196
+ user_id=user_id,
197
+ service_id=service_id,
198
+ details=details,
199
+ severity=severity,
200
+ metadata=metadata or {},
201
+ )
202
+
203
+ with self._lock:
204
+ self._events.append(event)
205
+ if len(self._events) > self.max_events:
206
+ self._events = self._events[-self.max_events:]
207
+
208
+ # Log to file
209
+ try:
210
+ with open(self.log_path, "a") as f:
211
+ f.write(json.dumps(event.to_dict()) + "\n")
212
+ except Exception as e:
213
+ anomaly_logger.error(f"Failed to write anomaly log: {e}")
214
+
215
+ # Log to Python logger
216
+ log_level = {
217
+ "low": logging.DEBUG,
218
+ "medium": logging.WARNING,
219
+ "high": logging.ERROR,
220
+ "critical": logging.CRITICAL,
221
+ }.get(severity, logging.WARNING)
222
+ anomaly_logger.log(log_level, event.to_log_line())
223
+
224
+ # Call callback if provided
225
+ if self.on_anomaly:
226
+ try:
227
+ self.on_anomaly(event)
228
+ except Exception:
229
+ pass
230
+
231
+ return event
232
+
233
+ def get_recent(self, count: int = 100) -> List[AnomalyEvent]:
234
+ """Get recent anomaly events."""
235
+ with self._lock:
236
+ return self._events[-count:]
237
+
238
+ def get_by_user(self, user_id: str, count: int = 100) -> List[AnomalyEvent]:
239
+ """Get anomalies for a specific user."""
240
+ with self._lock:
241
+ return [e for e in self._events if e.user_id == user_id][-count:]
242
+
243
+ def get_by_type(self, anomaly_type: AnomalyType, count: int = 100) -> List[AnomalyEvent]:
244
+ """Get anomalies of a specific type."""
245
+ with self._lock:
246
+ return [e for e in self._events if e.anomaly_type == anomaly_type][-count:]
247
+
248
+
249
+ class RateLimiter:
250
+ """Token bucket rate limiter."""
251
+
252
+ def __init__(
253
+ self,
254
+ requests_per_minute: int = 60,
255
+ burst_size: int = 10,
256
+ ):
257
+ self.requests_per_minute = requests_per_minute
258
+ self.burst_size = burst_size
259
+ self._buckets: Dict[str, Dict] = {}
260
+ self._lock = Lock()
261
+
262
+ def _get_bucket(self, key: str) -> Dict:
263
+ """Get or create a token bucket for a key."""
264
+ now = time.time()
265
+ with self._lock:
266
+ if key not in self._buckets:
267
+ self._buckets[key] = {
268
+ "tokens": self.burst_size,
269
+ "last_update": now,
270
+ }
271
+ bucket = self._buckets[key]
272
+
273
+ # Refill tokens based on time elapsed
274
+ elapsed = now - bucket["last_update"]
275
+ refill = elapsed * (self.requests_per_minute / 60.0)
276
+ bucket["tokens"] = min(self.burst_size, bucket["tokens"] + refill)
277
+ bucket["last_update"] = now
278
+
279
+ return bucket
280
+
281
+ def check(self, key: str) -> bool:
282
+ """Check if request is allowed (doesn't consume token)."""
283
+ bucket = self._get_bucket(key)
284
+ return bucket["tokens"] >= 1.0
285
+
286
+ def consume(self, key: str) -> bool:
287
+ """Try to consume a token. Returns True if allowed."""
288
+ bucket = self._get_bucket(key)
289
+ with self._lock:
290
+ if bucket["tokens"] >= 1.0:
291
+ bucket["tokens"] -= 1.0
292
+ return True
293
+ return False
294
+
295
+ def get_wait_time(self, key: str) -> float:
296
+ """Get seconds to wait before next request is allowed."""
297
+ bucket = self._get_bucket(key)
298
+ if bucket["tokens"] >= 1.0:
299
+ return 0.0
300
+ tokens_needed = 1.0 - bucket["tokens"]
301
+ return tokens_needed / (self.requests_per_minute / 60.0)
302
+
303
+
304
+ class ResourceMonitor:
305
+ """Monitors system resources and detects overload."""
306
+
307
+ def __init__(
308
+ self,
309
+ cpu_threshold: float = 80.0,
310
+ memory_threshold: float = 85.0,
311
+ check_interval: float = 5.0,
312
+ ):
313
+ self.cpu_threshold = cpu_threshold
314
+ self.memory_threshold = memory_threshold
315
+ self.check_interval = check_interval
316
+ self._last_check = 0.0
317
+ self._is_overloaded = False
318
+ self._lock = Lock()
319
+
320
+ def _get_cpu_percent(self) -> float:
321
+ """Get current CPU usage percentage."""
322
+ try:
323
+ with open("/proc/stat", "r") as f:
324
+ line = f.readline()
325
+ parts = line.split()[1:5]
326
+ user, nice, system, idle = map(int, parts)
327
+ total = user + nice + system + idle
328
+ used = user + nice + system
329
+ return (used / total) * 100 if total > 0 else 0.0
330
+ except:
331
+ return 0.0
332
+
333
+ def _get_memory_percent(self) -> float:
334
+ """Get current memory usage percentage."""
335
+ try:
336
+ with open("/proc/meminfo", "r") as f:
337
+ lines = f.readlines()
338
+ mem_info = {}
339
+ for line in lines[:5]:
340
+ parts = line.split()
341
+ mem_info[parts[0].rstrip(":")] = int(parts[1])
342
+ total = mem_info.get("MemTotal", 1)
343
+ available = mem_info.get("MemAvailable", mem_info.get("MemFree", 0))
344
+ used = total - available
345
+ return (used / total) * 100 if total > 0 else 0.0
346
+ except:
347
+ return 0.0
348
+
349
+ def check_overload(self) -> tuple[bool, Dict[str, float]]:
350
+ """Check if system is overloaded. Returns (is_overloaded, metrics)."""
351
+ now = time.time()
352
+
353
+ with self._lock:
354
+ if now - self._last_check < self.check_interval:
355
+ return self._is_overloaded, {}
356
+
357
+ self._last_check = now
358
+
359
+ cpu = self._get_cpu_percent()
360
+ memory = self._get_memory_percent()
361
+
362
+ self._is_overloaded = (
363
+ cpu > self.cpu_threshold or
364
+ memory > self.memory_threshold
365
+ )
366
+
367
+ return self._is_overloaded, {
368
+ "cpu_percent": cpu,
369
+ "memory_percent": memory,
370
+ "cpu_threshold": self.cpu_threshold,
371
+ "memory_threshold": self.memory_threshold,
372
+ }
373
+
374
+ def get_throttle_delay(self) -> float:
375
+ """Get delay in seconds based on current load."""
376
+ is_overloaded, metrics = self.check_overload()
377
+ if not is_overloaded:
378
+ return 0.0
379
+
380
+ # Calculate delay based on how much we're over threshold
381
+ cpu_over = max(0, metrics.get("cpu_percent", 0) - self.cpu_threshold)
382
+ mem_over = max(0, metrics.get("memory_percent", 0) - self.memory_threshold)
383
+
384
+ # Delay scales with overload: 0.5s base + up to 5s based on severity
385
+ max_over = max(cpu_over, mem_over)
386
+ return min(5.0, 0.5 + (max_over / 20.0) * 4.5)
387
+
388
+
389
+ @dataclass
390
+ class SecurityCheckResult:
391
+ """Result of a security check."""
392
+ allowed: bool
393
+ reason: Optional[str] = None
394
+ delay_seconds: float = 0.0
395
+ anomaly: Optional[AnomalyEvent] = None
396
+
397
+ def to_dict(self) -> dict:
398
+ return {
399
+ "allowed": self.allowed,
400
+ "reason": self.reason,
401
+ "delay_seconds": self.delay_seconds,
402
+ "anomaly": self.anomaly.to_dict() if self.anomaly else None,
403
+ }
404
+
405
+
406
+ class SecurityPolicy:
407
+ """
408
+ Main security policy enforcer for pactown.
409
+
410
+ Combines rate limiting, resource monitoring, user profiles,
411
+ and anomaly logging into a unified security layer.
412
+ """
413
+
414
+ def __init__(
415
+ self,
416
+ anomaly_log_path: Optional[Path] = None,
417
+ default_rate_limit: int = 60,
418
+ cpu_threshold: float = 80.0,
419
+ memory_threshold: float = 85.0,
420
+ on_anomaly: Optional[Callable[[AnomalyEvent], None]] = None,
421
+ ):
422
+ self.anomaly_logger = AnomalyLogger(
423
+ log_path=anomaly_log_path,
424
+ on_anomaly=on_anomaly,
425
+ )
426
+ self.rate_limiter = RateLimiter(requests_per_minute=default_rate_limit)
427
+ self.resource_monitor = ResourceMonitor(
428
+ cpu_threshold=cpu_threshold,
429
+ memory_threshold=memory_threshold,
430
+ )
431
+
432
+ self._user_profiles: Dict[str, UserProfile] = {}
433
+ self._user_services: Dict[str, List[str]] = {} # user_id -> [service_ids]
434
+ self._service_starts: Dict[str, List[float]] = {} # user_id -> [timestamps]
435
+ self._lock = Lock()
436
+
437
+ def set_user_profile(self, profile: UserProfile) -> None:
438
+ """Set or update a user profile."""
439
+ with self._lock:
440
+ self._user_profiles[profile.user_id] = profile
441
+
442
+ def get_user_profile(self, user_id: str) -> UserProfile:
443
+ """Get user profile, creating default if not exists."""
444
+ with self._lock:
445
+ if user_id not in self._user_profiles:
446
+ self._user_profiles[user_id] = UserProfile.from_tier(user_id, UserTier.FREE)
447
+ return self._user_profiles[user_id]
448
+
449
+ def register_service(self, user_id: str, service_id: str) -> None:
450
+ """Register a running service for a user."""
451
+ with self._lock:
452
+ if user_id not in self._user_services:
453
+ self._user_services[user_id] = []
454
+ if service_id not in self._user_services[user_id]:
455
+ self._user_services[user_id].append(service_id)
456
+
457
+ # Track service start time
458
+ if user_id not in self._service_starts:
459
+ self._service_starts[user_id] = []
460
+ self._service_starts[user_id].append(time.time())
461
+
462
+ # Clean old entries (older than 1 hour)
463
+ cutoff = time.time() - 3600
464
+ self._service_starts[user_id] = [
465
+ t for t in self._service_starts[user_id] if t > cutoff
466
+ ]
467
+
468
+ def unregister_service(self, user_id: str, service_id: str) -> None:
469
+ """Unregister a stopped service."""
470
+ with self._lock:
471
+ if user_id in self._user_services:
472
+ if service_id in self._user_services[user_id]:
473
+ self._user_services[user_id].remove(service_id)
474
+
475
+ def get_user_service_count(self, user_id: str) -> int:
476
+ """Get number of running services for a user."""
477
+ with self._lock:
478
+ return len(self._user_services.get(user_id, []))
479
+
480
+ def get_services_started_last_hour(self, user_id: str) -> int:
481
+ """Get number of services started in the last hour."""
482
+ with self._lock:
483
+ cutoff = time.time() - 3600
484
+ starts = self._service_starts.get(user_id, [])
485
+ return len([t for t in starts if t > cutoff])
486
+
487
+ async def check_can_start_service(
488
+ self,
489
+ user_id: str,
490
+ service_id: str,
491
+ port: Optional[int] = None,
492
+ ) -> SecurityCheckResult:
493
+ """
494
+ Check if a user can start a new service.
495
+
496
+ Returns SecurityCheckResult with allowed status and any required delay.
497
+ """
498
+ profile = self.get_user_profile(user_id)
499
+
500
+ # Check if user is blocked
501
+ if profile.blocked:
502
+ anomaly = self.anomaly_logger.log(
503
+ AnomalyType.UNAUTHORIZED_ACCESS,
504
+ f"Blocked user {user_id} attempted to start service",
505
+ user_id=user_id,
506
+ service_id=service_id,
507
+ severity="high",
508
+ )
509
+ return SecurityCheckResult(
510
+ allowed=False,
511
+ reason=f"User blocked: {profile.reason or 'No reason provided'}",
512
+ anomaly=anomaly,
513
+ )
514
+
515
+ # Check rate limit
516
+ rate_key = f"user:{user_id}:start"
517
+ if not self.rate_limiter.check(rate_key):
518
+ wait_time = self.rate_limiter.get_wait_time(rate_key)
519
+ anomaly = self.anomaly_logger.log(
520
+ AnomalyType.RATE_LIMIT_EXCEEDED,
521
+ f"User {user_id} exceeded rate limit for service starts",
522
+ user_id=user_id,
523
+ service_id=service_id,
524
+ severity="medium",
525
+ metadata={"wait_time": wait_time},
526
+ )
527
+ return SecurityCheckResult(
528
+ allowed=False,
529
+ reason=f"Rate limit exceeded. Wait {wait_time:.1f}s",
530
+ delay_seconds=wait_time,
531
+ anomaly=anomaly,
532
+ )
533
+
534
+ # Check concurrent service limit
535
+ current_count = self.get_user_service_count(user_id)
536
+ if current_count >= profile.max_concurrent_services:
537
+ anomaly = self.anomaly_logger.log(
538
+ AnomalyType.CONCURRENT_LIMIT_EXCEEDED,
539
+ f"User {user_id} at max concurrent services ({current_count}/{profile.max_concurrent_services})",
540
+ user_id=user_id,
541
+ service_id=service_id,
542
+ severity="medium",
543
+ metadata={
544
+ "current": current_count,
545
+ "max": profile.max_concurrent_services,
546
+ },
547
+ )
548
+ return SecurityCheckResult(
549
+ allowed=False,
550
+ reason=f"Max concurrent services reached ({current_count}/{profile.max_concurrent_services}). Stop a service first.",
551
+ anomaly=anomaly,
552
+ )
553
+
554
+ # Check hourly service limit
555
+ hourly_count = self.get_services_started_last_hour(user_id)
556
+ if hourly_count >= profile.max_services_per_hour:
557
+ anomaly = self.anomaly_logger.log(
558
+ AnomalyType.RATE_LIMIT_EXCEEDED,
559
+ f"User {user_id} exceeded hourly service limit ({hourly_count}/{profile.max_services_per_hour})",
560
+ user_id=user_id,
561
+ service_id=service_id,
562
+ severity="medium",
563
+ )
564
+ return SecurityCheckResult(
565
+ allowed=False,
566
+ reason=f"Hourly service limit reached ({hourly_count}/{profile.max_services_per_hour}). Try again later.",
567
+ anomaly=anomaly,
568
+ )
569
+
570
+ # Check port restrictions
571
+ if port and profile.allowed_ports:
572
+ if port not in profile.allowed_ports:
573
+ anomaly = self.anomaly_logger.log(
574
+ AnomalyType.UNAUTHORIZED_ACCESS,
575
+ f"User {user_id} attempted to use restricted port {port}",
576
+ user_id=user_id,
577
+ service_id=service_id,
578
+ severity="high",
579
+ metadata={"port": port, "allowed": profile.allowed_ports},
580
+ )
581
+ return SecurityCheckResult(
582
+ allowed=False,
583
+ reason=f"Port {port} not allowed for your account",
584
+ anomaly=anomaly,
585
+ )
586
+
587
+ # Check system resources
588
+ is_overloaded, metrics = self.resource_monitor.check_overload()
589
+ if is_overloaded:
590
+ delay = self.resource_monitor.get_throttle_delay()
591
+ anomaly = self.anomaly_logger.log(
592
+ AnomalyType.SERVER_OVERLOADED,
593
+ f"Server overloaded, throttling user {user_id}",
594
+ user_id=user_id,
595
+ service_id=service_id,
596
+ severity="medium",
597
+ metadata=metrics,
598
+ )
599
+
600
+ # For free tier, deny during overload
601
+ if profile.tier == UserTier.FREE:
602
+ return SecurityCheckResult(
603
+ allowed=False,
604
+ reason="Server is currently overloaded. Please try again later.",
605
+ delay_seconds=delay,
606
+ anomaly=anomaly,
607
+ )
608
+
609
+ # For paid tiers, allow but with delay
610
+ return SecurityCheckResult(
611
+ allowed=True,
612
+ reason=f"Server under load, request delayed by {delay:.1f}s",
613
+ delay_seconds=delay,
614
+ anomaly=anomaly,
615
+ )
616
+
617
+ # Check for rapid restart pattern (potential abuse)
618
+ starts = self._service_starts.get(user_id, [])
619
+ recent_starts = [t for t in starts if time.time() - t < 60] # Last minute
620
+ if len(recent_starts) >= 5:
621
+ anomaly = self.anomaly_logger.log(
622
+ AnomalyType.RAPID_RESTART,
623
+ f"User {user_id} showing rapid restart pattern ({len(recent_starts)} in 60s)",
624
+ user_id=user_id,
625
+ service_id=service_id,
626
+ severity="medium",
627
+ metadata={"restarts_last_minute": len(recent_starts)},
628
+ )
629
+ # Allow but log for monitoring
630
+
631
+ # Consume rate limit token
632
+ self.rate_limiter.consume(rate_key)
633
+
634
+ return SecurityCheckResult(allowed=True)
635
+
636
+ def get_anomaly_summary(self, hours: int = 24) -> Dict[str, Any]:
637
+ """Get summary of anomalies for admin dashboard."""
638
+ cutoff = datetime.now(UTC).timestamp() - (hours * 3600)
639
+ recent = [
640
+ e for e in self.anomaly_logger.get_recent(1000)
641
+ if e.timestamp.timestamp() > cutoff
642
+ ]
643
+
644
+ by_type: Dict[str, int] = {}
645
+ by_severity: Dict[str, int] = {}
646
+ by_user: Dict[str, int] = {}
647
+
648
+ for event in recent:
649
+ by_type[event.anomaly_type.value] = by_type.get(event.anomaly_type.value, 0) + 1
650
+ by_severity[event.severity] = by_severity.get(event.severity, 0) + 1
651
+ if event.user_id:
652
+ by_user[event.user_id] = by_user.get(event.user_id, 0) + 1
653
+
654
+ return {
655
+ "period_hours": hours,
656
+ "total_anomalies": len(recent),
657
+ "by_type": by_type,
658
+ "by_severity": by_severity,
659
+ "top_users": dict(sorted(by_user.items(), key=lambda x: -x[1])[:10]),
660
+ "recent_critical": [
661
+ e.to_dict() for e in recent
662
+ if e.severity == "critical"
663
+ ][-10:],
664
+ }
665
+
666
+
667
+ # Global default policy instance
668
+ _default_policy: Optional[SecurityPolicy] = None
669
+
670
+
671
+ def get_security_policy() -> SecurityPolicy:
672
+ """Get the global security policy instance."""
673
+ global _default_policy
674
+ if _default_policy is None:
675
+ _default_policy = SecurityPolicy()
676
+ return _default_policy
677
+
678
+
679
+ def set_security_policy(policy: SecurityPolicy) -> None:
680
+ """Set the global security policy instance."""
681
+ global _default_policy
682
+ _default_policy = policy