diff --git a/Dockerfile b/Dockerfile index 0ce7148..96e40f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,9 +41,10 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean -# Create non-root user for security and add to docker group with correct GID -RUN groupadd -r appuser && useradd -r -g appuser appuser && \ - groupadd -g 113 docker && usermod -aG docker appuser +# Create non-root user with explicit UID 1000 for consistent volume permissions +# The docker group GID 988 matches the host's docker group for socket access +RUN groupadd -r -g 1000 appuser && useradd -r -u 1000 -g appuser appuser && \ + groupadd -g 988 docker && usermod -aG docker appuser # Set working directory WORKDIR /app @@ -58,9 +59,9 @@ COPY dashboard/ ./dashboard/ COPY .env.example . -# Create necessary directories +# Create necessary directories with correct ownership RUN mkdir -p /app/logs /app/data /app/ssl && \ - chown -R appuser:appuser /app + chown -R 1000:1000 /app # Switch to non-root user USER appuser diff --git a/dashboard/static/css/style.css b/dashboard/static/css/style.css index 9566faa..3d7f621 100644 --- a/dashboard/static/css/style.css +++ b/dashboard/static/css/style.css @@ -323,6 +323,11 @@ td { color: var(--accent-red); } +.badge-warning { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; +} + /* Buttons */ .btn { padding: 0.5rem 1rem; diff --git a/dashboard/static/js/app.js b/dashboard/static/js/app.js index eb86d33..bce8320 100644 --- a/dashboard/static/js/app.js +++ b/dashboard/static/js/app.js @@ -258,10 +258,10 @@ function populateApiKeyDropdown() { select.innerHTML = '' + state.apiKeyList - .map( - (k) => - ``, - ) + .map((k) => { + const envIndicator = k.source === "environment" ? " [ENV]" : ""; + return ``; + }) .join(""); } @@ -642,25 +642,20 @@ function renderKeysTable() { if (state.keys.length === 0) { tbody.innerHTML = - 'No managed API keys found.'; + 'No API keys found.'; return; } tbody.innerHTML = state.keys - .map( - (key) => ` - - ${key.name || "Unnamed"} - ${key.key_prefix || "---"}... - - - ${key.enabled ? "Active" : "Disabled"} - - - ${new Date(key.created_at).toLocaleDateString()} - ${key.usage_count || 0} - ${formatRateLimits(key.rate_limits)} - + .map((key) => { + const isEnvKey = key.source === "environment"; + const statusBadge = isEnvKey + ? 'Environment' + : `${key.enabled ? "Active" : "Disabled"}`; + + const actionsHtml = isEnvKey + ? 'Read-only' + : ` @@ -669,11 +664,19 @@ function renderKeysTable() { - - - `, - ) + `; + + return ` + + ${key.name || "Unnamed"} + ${key.key_prefix || "---"}... + ${statusBadge} + ${new Date(key.created_at).toLocaleDateString()} + ${key.usage_count || 0} + ${formatRateLimits(key.rate_limits)} + ${actionsHtml} + `; + }) .join(""); initLucide(); diff --git a/docker-compose.yml b/docker-compose.yml index 0331295..0df23be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - ./logs:/app/logs - - ./data:/app/data + - app-data:/app/data - ${SSL_CERTS_PATH:-./ssl}:/app/ssl - ./dashboard:/app/dashboard - ./src:/app/src @@ -139,6 +139,8 @@ volumes: driver: local minio-data: driver: local + app-data: + driver: local networks: code-interpreter-network: diff --git a/src/api/admin.py b/src/api/admin.py index 7986953..77653a9 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -47,6 +47,7 @@ class ApiKeyResponse(BaseModel): metadata: Dict[str, str] last_used_at: Optional[datetime] = None usage_count: int + source: str = "managed" # "managed" or "environment" # --- Dependencies --- @@ -70,9 +71,9 @@ async def verify_master_key(x_api_key: str = Header(...)): @router.get("/keys", response_model=List[ApiKeyResponse]) async def list_keys(_: str = Depends(verify_master_key)): - """List all managed API keys.""" + """List all API keys including environment keys (read-only).""" manager = await get_api_key_manager() - records = await manager.list_keys() + records = await manager.list_keys(include_env_keys=True) return [ ApiKeyResponse( @@ -85,6 +86,7 @@ async def list_keys(_: str = Depends(verify_master_key)): metadata=r.metadata, last_used_at=r.last_used_at, usage_count=r.usage_count, + source=r.source, ) for r in records ] @@ -121,6 +123,7 @@ async def create_key(data: ApiKeyCreate, _: str = Depends(verify_master_key)): metadata=record.metadata, last_used_at=record.last_used_at, usage_count=record.usage_count, + source=record.source, ), } @@ -132,6 +135,14 @@ async def update_key( """Update an API key.""" manager = await get_api_key_manager() + # Check if this is an env key (not allowed to modify) + record = await manager.get_key(key_hash) + if record and record.source == "environment": + raise HTTPException( + status_code=403, + detail="Environment keys cannot be modified. Update the API_KEY environment variable instead.", + ) + rate_limits = None if data.rate_limits: rate_limits = RateLimitsModel( @@ -156,6 +167,15 @@ async def update_key( async def revoke_key(key_hash: str, _: str = Depends(verify_master_key)): """Revoke an API key.""" manager = await get_api_key_manager() + + # Check if this is an env key (not allowed to revoke) + record = await manager.get_key(key_hash) + if record and record.source == "environment": + raise HTTPException( + status_code=403, + detail="Environment keys cannot be revoked. Remove the API_KEY environment variable instead.", + ) + success = await manager.revoke_key(key_hash) if not success: diff --git a/src/api/dashboard_metrics.py b/src/api/dashboard_metrics.py index 034852a..8529a27 100644 --- a/src/api/dashboard_metrics.py +++ b/src/api/dashboard_metrics.py @@ -109,6 +109,7 @@ class ApiKeyFilterOption(BaseModel): name: str key_prefix: str usage_count: int + source: str = "managed" # "managed" or "environment" # --- Endpoints --- @@ -228,10 +229,10 @@ async def get_activity_heatmap( @router.get("/api-keys", response_model=List[ApiKeyFilterOption]) async def get_api_keys_for_filter(_: str = Depends(verify_master_key)): - """Get list of API keys for filter dropdown.""" - # Get API keys from manager (with names) + """Get list of API keys for filter dropdown (includes env keys).""" + # Get API keys from manager (with names), including env keys manager = await get_api_key_manager() - managed_keys = await manager.list_keys() + managed_keys = await manager.list_keys(include_env_keys=True) # Build lookup by key_hash key_lookup = {k.key_hash: k for k in managed_keys} @@ -240,9 +241,12 @@ async def get_api_keys_for_filter(_: str = Depends(verify_master_key)): sqlite_keys = await sqlite_metrics_service.get_api_keys_list() result = [] + seen_hashes = set() + for sk in sqlite_keys: key_hash = sk["key_hash"] managed = key_lookup.get(key_hash) + seen_hashes.add(key_hash) result.append( ApiKeyFilterOption( @@ -250,9 +254,23 @@ async def get_api_keys_for_filter(_: str = Depends(verify_master_key)): name=managed.name if managed else f"Key {key_hash[:8]}", key_prefix=managed.key_prefix if managed else key_hash[:12], usage_count=sk["usage_count"], + source=managed.source if managed else "managed", ) ) + # Add env keys that might not have SQLite usage yet + for key in managed_keys: + if key.source == "environment" and key.key_hash not in seen_hashes: + result.append( + ApiKeyFilterOption( + key_hash=key.key_hash, + name=key.name, + key_prefix=key.key_prefix, + usage_count=key.usage_count, + source="environment", + ) + ) + return result diff --git a/src/middleware/security.py b/src/middleware/security.py index 6cc4539..cb5a113 100644 --- a/src/middleware/security.py +++ b/src/middleware/security.py @@ -215,9 +215,11 @@ async def _authenticate_request(self, request: Request, scope: dict): scope["state"]["api_key_hash"] = result.key_hash scope["state"]["is_env_key"] = result.is_env_key - # Record usage for Redis-managed keys (not env var keys) - if not result.is_env_key and result.key_hash: - await auth_service.record_usage(result.key_hash, is_env_key=False) + # Record usage for all keys (both managed and env keys) + if result.key_hash: + await auth_service.record_usage( + result.key_hash, is_env_key=result.is_env_key + ) def _extract_api_key(self, request: Request) -> Optional[str]: """Extract API key from request headers.""" diff --git a/src/models/api_key.py b/src/models/api_key.py index e4d7320..226e03d 100644 --- a/src/models/api_key.py +++ b/src/models/api_key.py @@ -67,6 +67,9 @@ class ApiKeyRecord: metadata: Dict[str, str] = field(default_factory=dict) last_used_at: Optional[datetime] = None usage_count: int = 0 + source: str = ( + "managed" # "managed" for Redis-managed, "environment" for env var keys + ) def to_redis_hash(self) -> Dict[str, str]: """Convert to Redis hash format (all string values).""" @@ -104,6 +107,7 @@ def to_redis_hash(self) -> Dict[str, str]: "metadata": json.dumps(self.metadata), "last_used_at": self.last_used_at.isoformat() if self.last_used_at else "", "usage_count": str(self.usage_count), + "source": self.source, } @classmethod @@ -167,6 +171,7 @@ def from_redis_hash(cls, data: Dict[bytes, bytes]) -> "ApiKeyRecord": metadata=metadata, last_used_at=last_used_at, usage_count=int(decoded.get("usage_count", "0")), + source=decoded.get("source", "managed"), ) def to_display_dict(self) -> Dict[str, Any]: @@ -185,6 +190,7 @@ def to_display_dict(self) -> Dict[str, Any]: "daily": self.rate_limits.daily, "monthly": self.rate_limits.monthly, }, + "source": self.source, } diff --git a/src/services/api_key_manager.py b/src/services/api_key_manager.py index 03e0cf2..5e30be2 100644 --- a/src/services/api_key_manager.py +++ b/src/services/api_key_manager.py @@ -36,6 +36,7 @@ class ApiKeyManagerService: VALID_CACHE_PREFIX = "api_keys:valid:" USAGE_PREFIX = "api_keys:usage:" INDEX_KEY = "api_keys:index" + ENV_KEYS_INDEX = "api_keys:env_index" # Separate index for env keys # Cache TTL VALIDATION_CACHE_TTL = 300 # 5 minutes @@ -63,6 +64,153 @@ def _short_hash(self, key_hash: str) -> str: """Get short version of hash for caching.""" return key_hash[:16] + async def ensure_env_key_records(self) -> List[ApiKeyRecord]: + """Ensure env key records exist in Redis for visibility. + + Creates or updates records for API_KEY and API_KEYS env vars. + These are read-only records for dashboard visibility. + + Returns: + List of env key records + """ + env_records = [] + + # Primary API_KEY + primary_key = settings.api_key + if primary_key and primary_key != "test-api-key": + record = await self._ensure_single_env_key_record( + primary_key, "Environment Key (API_KEY)" + ) + if record: + env_records.append(record) + + # Additional API_KEYS + additional_keys = settings.get_valid_api_keys() + for idx, key in enumerate(additional_keys): + name = f"Environment Key (API_KEYS #{idx + 1})" + record = await self._ensure_single_env_key_record(key, name) + if record: + env_records.append(record) + + return env_records + + async def _ensure_single_env_key_record( + self, api_key: str, name: str + ) -> Optional[ApiKeyRecord]: + """Create or update a single env key record. + + Args: + api_key: The actual API key value + name: Human-readable name for the key + + Returns: + ApiKeyRecord or None on error + """ + try: + key_hash = self._hash_key(api_key) + record_key = f"{self.RECORD_PREFIX}{key_hash}" + + # Check if record already exists + existing = await self.redis.hgetall(record_key) + + if existing: + # Update existing record to ensure it has correct source + record = ApiKeyRecord.from_redis_hash(existing) + if record.source != "environment": + record.source = "environment" + record.name = name + await self.redis.hset(record_key, mapping=record.to_redis_hash()) + return record + + # Create new record + record = ApiKeyRecord( + key_hash=key_hash, + key_prefix=api_key[:11] if len(api_key) >= 11 else api_key, + name=name, + created_at=datetime.now(timezone.utc), + enabled=True, + rate_limits=RateLimits(), # Unlimited + metadata={"type": "environment"}, + source="environment", + ) + + # Store in Redis + pipe = self.redis.pipeline(transaction=True) + pipe.hset(record_key, mapping=record.to_redis_hash()) + pipe.sadd(self.ENV_KEYS_INDEX, key_hash) + await pipe.execute() + + logger.info( + "Created env key record for visibility", + name=name, + key_prefix=record.key_prefix, + ) + + return record + + except Exception as e: + logger.warning("Failed to ensure env key record", name=name, error=str(e)) + return None + + async def get_env_key_records(self) -> List[ApiKeyRecord]: + """Get all env key records. + + Returns: + List of env key records + """ + try: + key_hashes = await self.redis.smembers(self.ENV_KEYS_INDEX) + records = [] + + for key_hash in key_hashes: + hash_str = ( + key_hash.decode() if isinstance(key_hash, bytes) else key_hash + ) + record = await self.get_key(hash_str) + if record: + records.append(record) + + return records + except Exception as e: + logger.warning("Failed to get env key records", error=str(e)) + return [] + + async def increment_env_key_usage(self, key_hash: str) -> None: + """Increment usage counters for an env key. + + Similar to increment_usage but handles env keys that may not have + a record yet (creates minimal tracking). + + Args: + key_hash: Full SHA256 hash of the key + """ + now = datetime.now(timezone.utc) + record_key = f"{self.RECORD_PREFIX}{key_hash}" + + try: + # Check if record exists + exists = await self.redis.exists(record_key) + + if exists: + # Update usage count and last_used_at + pipe = self.redis.pipeline(transaction=False) + pipe.hincrby(record_key, "usage_count", 1) + pipe.hset(record_key, "last_used_at", now.isoformat()) + await pipe.execute() + else: + # Record doesn't exist yet - ensure it gets created + await self.ensure_env_key_records() + # Try to update again + exists = await self.redis.exists(record_key) + if exists: + pipe = self.redis.pipeline(transaction=False) + pipe.hincrby(record_key, "usage_count", 1) + pipe.hset(record_key, "last_used_at", now.isoformat()) + await pipe.execute() + + except Exception as e: + logger.warning("Failed to increment env key usage", error=str(e)) + async def create_key( self, name: str, @@ -128,9 +276,12 @@ async def get_key(self, key_hash: str) -> Optional[ApiKeyRecord]: return ApiKeyRecord.from_redis_hash(data) - async def list_keys(self) -> List[ApiKeyRecord]: + async def list_keys(self, include_env_keys: bool = True) -> List[ApiKeyRecord]: """List all API keys (without the actual key values). + Args: + include_env_keys: Whether to include environment key records + Returns: List of ApiKeyRecord objects """ @@ -143,7 +294,19 @@ async def list_keys(self) -> List[ApiKeyRecord]: if record: records.append(record) - # Sort by created_at descending + # Include env keys if requested + if include_env_keys: + env_records = await self.get_env_key_records() + # Add env records that aren't already in the list + existing_hashes = {r.key_hash for r in records} + for env_record in env_records: + if env_record.key_hash not in existing_hashes: + records.append(env_record) + + # Sort by source (env keys first), then created_at descending + records.sort( + key=lambda r: (r.source != "environment", r.created_at), reverse=False + ) records.sort(key=lambda r: r.created_at, reverse=True) return records @@ -585,4 +748,10 @@ async def get_api_key_manager() -> ApiKeyManagerService: _api_key_manager = ApiKeyManagerService(redis_client) logger.info("Initialized API key manager service") + # Ensure env key records exist for dashboard visibility + try: + await _api_key_manager.ensure_env_key_records() + except Exception as e: + logger.warning("Failed to ensure env key records on startup", error=str(e)) + return _api_key_manager diff --git a/src/services/auth.py b/src/services/auth.py index ad013aa..be9d22e 100644 --- a/src/services/auth.py +++ b/src/services/auth.py @@ -17,7 +17,7 @@ # Local application imports from ..config import settings -from ..models.api_key import KeyValidationResult, RateLimitStatus +from ..models.api_key import KeyValidationResult logger = structlog.get_logger(__name__) @@ -145,11 +145,13 @@ async def record_usage(self, key_hash: str, is_env_key: bool = False) -> None: key_hash: Hash of the API key is_env_key: True if this is the env var key (no rate limiting) """ - if is_env_key: - return # Don't track usage for env var keys - try: - await self.api_key_manager.increment_usage(key_hash) + if is_env_key: + # Track usage for env keys (for dashboard visibility) + await self.api_key_manager.increment_env_key_usage(key_hash) + else: + # Track usage for managed keys (includes rate limit counters) + await self.api_key_manager.increment_usage(key_hash) except Exception as e: logger.warning("Failed to record usage", error=str(e))