Spaces:
Running
Running
Xenon010101 commited on
Commit ·
7f97cc1
1
Parent(s): 10f60a6
feat(api): add API Key Management system for programmatic access
Browse files- Add
ame and is_active columns to ApiKey model
- Rename last_used to last_used_at for consistency
- Add schema migrations for api_keys table
- Fix create/list/delete endpoints to return proper response models
- Support optional
ame field when creating keys
- Change key prefix from
ag_ to pdf_rag_
- Check is_active in API key authentication middleware
Closes #284
- backend/app/auth.py +4 -4
- backend/app/database.py +21 -0
- backend/app/models.py +4 -2
- backend/app/routes/auth.py +31 -9
- backend/app/schemas.py +9 -1
backend/app/auth.py
CHANGED
|
@@ -77,18 +77,18 @@ def get_current_user(
|
|
| 77 |
token = credentials.credentials
|
| 78 |
|
| 79 |
# Check if token is an API key
|
| 80 |
-
if token.startswith("
|
| 81 |
hashed = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
| 82 |
from app.models import ApiKey
|
| 83 |
-
api_key = db.query(ApiKey).filter(ApiKey.hashed_key == hashed).first()
|
| 84 |
if not api_key:
|
| 85 |
raise HTTPException(
|
| 86 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
detail="Invalid API key",
|
| 88 |
headers={"WWW-Authenticate": "Bearer"},
|
| 89 |
)
|
| 90 |
-
|
| 91 |
-
api_key.
|
| 92 |
db.commit()
|
| 93 |
|
| 94 |
user = api_key.user
|
|
|
|
| 77 |
token = credentials.credentials
|
| 78 |
|
| 79 |
# Check if token is an API key
|
| 80 |
+
if token.startswith("pdf_rag_"):
|
| 81 |
hashed = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
| 82 |
from app.models import ApiKey
|
| 83 |
+
api_key = db.query(ApiKey).filter(ApiKey.hashed_key == hashed, ApiKey.is_active == True).first()
|
| 84 |
if not api_key:
|
| 85 |
raise HTTPException(
|
| 86 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
detail="Invalid API key",
|
| 88 |
headers={"WWW-Authenticate": "Bearer"},
|
| 89 |
)
|
| 90 |
+
|
| 91 |
+
api_key.last_used_at = datetime.now(timezone.utc)
|
| 92 |
db.commit()
|
| 93 |
|
| 94 |
user = api_key.user
|
backend/app/database.py
CHANGED
|
@@ -71,6 +71,27 @@ def _migrate_schema():
|
|
| 71 |
"Migration skipped (may already exist): %s.%s", table, column
|
| 72 |
)
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# Migrate documents
|
| 75 |
existing_docs_columns = {c["name"] for c in inspector.get_columns("documents")}
|
| 76 |
docs_migrations = [
|
|
|
|
| 71 |
"Migration skipped (may already exist): %s.%s", table, column
|
| 72 |
)
|
| 73 |
|
| 74 |
+
# Migrate api_keys
|
| 75 |
+
try:
|
| 76 |
+
existing_keys_columns = {c["name"] for c in inspector.get_columns("api_keys")}
|
| 77 |
+
except Exception:
|
| 78 |
+
existing_keys_columns = set()
|
| 79 |
+
keys_migrations = [
|
| 80 |
+
("api_keys", "name", "ALTER TABLE api_keys ADD COLUMN name VARCHAR(100) DEFAULT 'default'"),
|
| 81 |
+
("api_keys", "is_active", "ALTER TABLE api_keys ADD COLUMN is_active BOOLEAN DEFAULT 1 NOT NULL"),
|
| 82 |
+
("api_keys", "last_used_at", "ALTER TABLE api_keys ADD COLUMN last_used_at TIMESTAMP"),
|
| 83 |
+
]
|
| 84 |
+
for table, column, ddl in keys_migrations:
|
| 85 |
+
if column not in existing_keys_columns:
|
| 86 |
+
try:
|
| 87 |
+
with engine.begin() as conn:
|
| 88 |
+
conn.execute(text(ddl))
|
| 89 |
+
logger.info("Migration: added column %s.%s", table, column)
|
| 90 |
+
except Exception:
|
| 91 |
+
logger.warning(
|
| 92 |
+
"Migration skipped (may already exist): %s.%s", table, column
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
# Migrate documents
|
| 96 |
existing_docs_columns = {c["name"] for c in inspector.get_columns("documents")}
|
| 97 |
docs_migrations = [
|
backend/app/models.py
CHANGED
|
@@ -134,10 +134,12 @@ class ApiKey(Base):
|
|
| 134 |
|
| 135 |
id = Column(GUID, primary_key=True, default=uuid.uuid4)
|
| 136 |
user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
|
| 137 |
-
|
|
|
|
| 138 |
hashed_key = Column(String(255), nullable=False, unique=True, index=True)
|
|
|
|
| 139 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 140 |
-
|
| 141 |
|
| 142 |
# Relationships
|
| 143 |
user = relationship("User", back_populates="api_keys")
|
|
|
|
| 134 |
|
| 135 |
id = Column(GUID, primary_key=True, default=uuid.uuid4)
|
| 136 |
user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
|
| 137 |
+
name = Column(String(100), nullable=False, default="default")
|
| 138 |
+
key_prefix = Column(String(20), nullable=False)
|
| 139 |
hashed_key = Column(String(255), nullable=False, unique=True, index=True)
|
| 140 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 141 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 142 |
+
last_used_at = Column(DateTime, nullable=True)
|
| 143 |
|
| 144 |
# Relationships
|
| 145 |
user = relationship("User", back_populates="api_keys")
|
backend/app/routes/auth.py
CHANGED
|
@@ -4,7 +4,7 @@ Auth API routes — register, login, and user profile.
|
|
| 4 |
import re
|
| 5 |
import secrets
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 8 |
from langsmith import expect
|
| 9 |
from sqlalchemy.exc import SQLAlchemyError
|
| 10 |
from sqlalchemy.orm import Session
|
|
@@ -419,26 +419,48 @@ from typing import List
|
|
| 419 |
import hashlib
|
| 420 |
|
| 421 |
@router.post("/api-keys", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
|
| 422 |
-
def create_api_key(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
"""Create a new API key for the authenticated user."""
|
| 424 |
-
|
|
|
|
| 425 |
hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
| 426 |
-
|
| 427 |
api_key = ApiKey(
|
| 428 |
user_id=user.id,
|
| 429 |
-
|
|
|
|
| 430 |
hashed_key=hashed_key,
|
|
|
|
| 431 |
)
|
| 432 |
db.add(api_key)
|
| 433 |
db.commit()
|
| 434 |
db.refresh(api_key)
|
| 435 |
-
|
| 436 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
@router.get("/api-keys", response_model=List[ApiKeyResponse])
|
| 439 |
def list_api_keys(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 440 |
"""List all API keys for the authenticated user."""
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 444 |
def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
@@ -446,7 +468,7 @@ def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Sess
|
|
| 446 |
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
| 447 |
if not api_key:
|
| 448 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 449 |
-
|
| 450 |
db.delete(api_key)
|
| 451 |
db.commit()
|
| 452 |
return None
|
|
|
|
| 4 |
import re
|
| 5 |
import secrets
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
+
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
| 8 |
from langsmith import expect
|
| 9 |
from sqlalchemy.exc import SQLAlchemyError
|
| 10 |
from sqlalchemy.orm import Session
|
|
|
|
| 419 |
import hashlib
|
| 420 |
|
| 421 |
@router.post("/api-keys", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
|
| 422 |
+
def create_api_key(
|
| 423 |
+
user: User = Depends(get_current_user),
|
| 424 |
+
db: Session = Depends(get_db),
|
| 425 |
+
body: dict = Body(None),
|
| 426 |
+
):
|
| 427 |
"""Create a new API key for the authenticated user."""
|
| 428 |
+
name = (body or {}).get("name", "default")
|
| 429 |
+
raw_key = "pdf_rag_" + secrets.token_hex(24)
|
| 430 |
hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
| 431 |
+
|
| 432 |
api_key = ApiKey(
|
| 433 |
user_id=user.id,
|
| 434 |
+
name=name,
|
| 435 |
+
key_prefix=raw_key[:15],
|
| 436 |
hashed_key=hashed_key,
|
| 437 |
+
is_active=True,
|
| 438 |
)
|
| 439 |
db.add(api_key)
|
| 440 |
db.commit()
|
| 441 |
db.refresh(api_key)
|
| 442 |
+
|
| 443 |
+
return ApiKeyCreateResponse(
|
| 444 |
+
id=str(api_key.id),
|
| 445 |
+
name=api_key.name,
|
| 446 |
+
key_preview=api_key.key_prefix,
|
| 447 |
+
created_at=api_key.created_at,
|
| 448 |
+
raw_key=raw_key,
|
| 449 |
+
)
|
| 450 |
|
| 451 |
@router.get("/api-keys", response_model=List[ApiKeyResponse])
|
| 452 |
def list_api_keys(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 453 |
"""List all API keys for the authenticated user."""
|
| 454 |
+
keys = db.query(ApiKey).filter(ApiKey.user_id == user.id, ApiKey.is_active == True).all()
|
| 455 |
+
return [
|
| 456 |
+
ApiKeyResponse(
|
| 457 |
+
id=str(k.id),
|
| 458 |
+
name=k.name,
|
| 459 |
+
key_preview=k.key_prefix,
|
| 460 |
+
created_at=k.created_at,
|
| 461 |
+
)
|
| 462 |
+
for k in keys
|
| 463 |
+
]
|
| 464 |
|
| 465 |
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 466 |
def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
|
|
| 468 |
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
| 469 |
if not api_key:
|
| 470 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 471 |
+
|
| 472 |
db.delete(api_key)
|
| 473 |
db.commit()
|
| 474 |
return None
|
backend/app/schemas.py
CHANGED
|
@@ -61,6 +61,7 @@ class HFTokenUpdate(BaseModel):
|
|
| 61 |
|
| 62 |
class ApiKeyResponse(BaseModel):
|
| 63 |
id: str
|
|
|
|
| 64 |
key_preview: str
|
| 65 |
created_at: datetime
|
| 66 |
|
|
@@ -68,9 +69,16 @@ class ApiKeyResponse(BaseModel):
|
|
| 68 |
from_attributes = True
|
| 69 |
|
| 70 |
|
| 71 |
-
class ApiKeyCreateResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
raw_key: str
|
| 73 |
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
class UserResponse(BaseModel):
|
| 76 |
id: str
|
|
|
|
| 61 |
|
| 62 |
class ApiKeyResponse(BaseModel):
|
| 63 |
id: str
|
| 64 |
+
name: str
|
| 65 |
key_preview: str
|
| 66 |
created_at: datetime
|
| 67 |
|
|
|
|
| 69 |
from_attributes = True
|
| 70 |
|
| 71 |
|
| 72 |
+
class ApiKeyCreateResponse(BaseModel):
|
| 73 |
+
id: str
|
| 74 |
+
name: str
|
| 75 |
+
key_preview: str
|
| 76 |
+
created_at: datetime
|
| 77 |
raw_key: str
|
| 78 |
|
| 79 |
+
class Config:
|
| 80 |
+
from_attributes = True
|
| 81 |
+
|
| 82 |
|
| 83 |
class UserResponse(BaseModel):
|
| 84 |
id: str
|