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))