Skip to content
Merged

Dev #20

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions dashboard/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 27 additions & 24 deletions dashboard/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,10 @@ function populateApiKeyDropdown() {
select.innerHTML =
'<option value="">All API Keys</option>' +
state.apiKeyList
.map(
(k) =>
`<option value="${k.key_hash}">${k.name} (${k.key_prefix}...)</option>`,
)
.map((k) => {
const envIndicator = k.source === "environment" ? " [ENV]" : "";
return `<option value="${k.key_hash}">${k.name}${envIndicator} (${k.key_prefix}...)</option>`;
})
.join("");
}

Expand Down Expand Up @@ -642,25 +642,20 @@ function renderKeysTable() {

if (state.keys.length === 0) {
tbody.innerHTML =
'<tr><td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">No managed API keys found.</td></tr>';
'<tr><td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">No API keys found.</td></tr>';
return;
}

tbody.innerHTML = state.keys
.map(
(key) => `
<tr>
<td>${key.name || "Unnamed"}</td>
<td><code>${key.key_prefix || "---"}...</code></td>
<td>
<span class="badge ${key.enabled ? "badge-success" : "badge-danger"}">
${key.enabled ? "Active" : "Disabled"}
</span>
</td>
<td>${new Date(key.created_at).toLocaleDateString()}</td>
<td>${key.usage_count || 0}</td>
<td>${formatRateLimits(key.rate_limits)}</td>
<td class="actions-cell">
.map((key) => {
const isEnvKey = key.source === "environment";
const statusBadge = isEnvKey
? '<span class="badge badge-warning">Environment</span>'
: `<span class="badge ${key.enabled ? "badge-success" : "badge-danger"}">${key.enabled ? "Active" : "Disabled"}</span>`;

const actionsHtml = isEnvKey
? '<span class="text-muted" style="font-size: 0.75rem;">Read-only</span>'
: `
<button class="btn btn-sm" data-action="edit-key" data-hash="${key.key_hash}" title="Edit">
<i data-lucide="edit-2" style="width: 14px;"></i>
</button>
Expand All @@ -669,11 +664,19 @@ function renderKeysTable() {
</button>
<button class="btn btn-sm btn-danger" data-action="revoke-key" data-hash="${key.key_hash}" title="Revoke">
<i data-lucide="trash-2" style="width: 14px;"></i>
</button>
</td>
</tr>
`,
)
</button>`;

return `
<tr${isEnvKey ? ' style="background: rgba(251, 191, 36, 0.05);"' : ""}>
<td>${key.name || "Unnamed"}</td>
<td><code>${key.key_prefix || "---"}...</code></td>
<td>${statusBadge}</td>
<td>${new Date(key.created_at).toLocaleDateString()}</td>
<td>${key.usage_count || 0}</td>
<td>${formatRateLimits(key.rate_limits)}</td>
<td class="actions-cell">${actionsHtml}</td>
</tr>`;
})
.join("");

initLucide();
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,6 +139,8 @@ volumes:
driver: local
minio-data:
driver: local
app-data:
driver: local

networks:
code-interpreter-network:
Expand Down
24 changes: 22 additions & 2 deletions src/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -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(
Expand All @@ -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
]
Expand Down Expand Up @@ -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,
),
}

Expand All @@ -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(
Expand All @@ -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:
Expand Down
24 changes: 21 additions & 3 deletions src/api/dashboard_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class ApiKeyFilterOption(BaseModel):
name: str
key_prefix: str
usage_count: int
source: str = "managed" # "managed" or "environment"


# --- Endpoints ---
Expand Down Expand Up @@ -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}
Expand All @@ -240,19 +241,36 @@ 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(
key_hash=key_hash,
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


Expand Down
8 changes: 5 additions & 3 deletions src/middleware/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
6 changes: 6 additions & 0 deletions src/models/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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,
}


Expand Down
Loading