Add user authentication system with API key management (#50)

- Add User, Session, AuthSettings models with bcrypt password hashing
- Add auth endpoints: login, logout, change-password, me
- Add API key CRUD: create (orch_xxx format), list, revoke
- Add admin user management: list, create, update, reset-password
- Create default admin user on startup (admin/admin)
- Add frontend: Login page, API Keys page, Admin Users page
- Add AuthContext for session state management
- Add user menu to Layout header with login/logout/settings
- Add 15 integration tests for auth system
- Add migration 006_auth_tables.sql
This commit is contained in:
Mondo Diaz
2026-01-08 15:01:37 -06:00
parent 1cbd335443
commit 2a68708a79
20 changed files with 4690 additions and 19 deletions

412
backend/app/auth.py Normal file
View File

@@ -0,0 +1,412 @@
"""Authentication service for Orchard.
Handles password hashing, session management, and API key operations.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .models import User, Session as UserSession, APIKey
# Password hashing context (bcrypt with cost factor 12)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# API key prefix
API_KEY_PREFIX = "orch_"
# Session duration (24 hours default)
SESSION_DURATION_HOURS = 24
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_token(token: str) -> str:
"""Hash a token (session or API key) using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
def generate_session_token() -> str:
"""Generate a cryptographically secure session token."""
return secrets.token_urlsafe(32)
def generate_api_key() -> str:
"""Generate a new API key with prefix.
Format: orch_<32 random bytes as hex>
"""
random_part = secrets.token_hex(32)
return f"{API_KEY_PREFIX}{random_part}"
class AuthService:
"""Authentication service for user management and session handling."""
def __init__(self, db: Session):
self.db = db
# --- User Operations ---
def create_user(
self,
username: str,
password: Optional[str] = None,
email: Optional[str] = None,
is_admin: bool = False,
must_change_password: bool = False,
) -> User:
"""Create a new user account."""
user = User(
username=username,
password_hash=hash_password(password) if password else None,
email=email,
is_admin=is_admin,
must_change_password=must_change_password,
)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def get_user_by_username(self, username: str) -> Optional[User]:
"""Get a user by username."""
return self.db.query(User).filter(User.username == username).first()
def get_user_by_id(self, user_id: str) -> Optional[User]:
"""Get a user by ID."""
return self.db.query(User).filter(User.id == user_id).first()
def authenticate_user(self, username: str, password: str) -> Optional[User]:
"""Authenticate a user with username and password.
Returns the user if authentication succeeds, None otherwise.
"""
user = self.get_user_by_username(username)
if not user:
return None
if not user.password_hash:
return None # OIDC-only user
if not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
def change_password(self, user: User, new_password: str) -> None:
"""Change a user's password."""
user.password_hash = hash_password(new_password)
user.must_change_password = False
self.db.commit()
def update_last_login(self, user: User) -> None:
"""Update the user's last login timestamp."""
user.last_login = datetime.utcnow()
self.db.commit()
def list_users(self, include_inactive: bool = False) -> list[User]:
"""List all users."""
query = self.db.query(User)
if not include_inactive:
query = query.filter(User.is_active == True)
return query.order_by(User.username).all()
def set_user_active(self, user: User, is_active: bool) -> None:
"""Enable or disable a user account."""
user.is_active = is_active
self.db.commit()
def set_user_admin(self, user: User, is_admin: bool) -> None:
"""Grant or revoke admin privileges."""
user.is_admin = is_admin
self.db.commit()
def reset_user_password(self, user: User, new_password: str) -> None:
"""Reset a user's password (admin action)."""
user.password_hash = hash_password(new_password)
user.must_change_password = True
self.db.commit()
# --- Session Operations ---
def create_session(
self,
user: User,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> tuple[UserSession, str]:
"""Create a new session for a user.
Returns a tuple of (session, token) where token is the plaintext
token that should be sent to the client. The token is only returned
once and should be stored securely.
"""
token = generate_session_token()
token_hash = hash_token(token)
session = UserSession(
user_id=user.id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=SESSION_DURATION_HOURS),
user_agent=user_agent,
ip_address=ip_address,
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session, token
def get_session_by_token(self, token: str) -> Optional[UserSession]:
"""Get a session by its token.
Returns None if the session doesn't exist or has expired.
"""
token_hash = hash_token(token)
session = (
self.db.query(UserSession)
.filter(UserSession.token_hash == token_hash)
.first()
)
if not session:
return None
if session.expires_at < datetime.utcnow():
# Session has expired, delete it
self.db.delete(session)
self.db.commit()
return None
# Update last accessed time
session.last_accessed = datetime.utcnow()
self.db.commit()
return session
def delete_session(self, session: UserSession) -> None:
"""Delete a session (logout)."""
self.db.delete(session)
self.db.commit()
def delete_user_sessions(self, user: User) -> int:
"""Delete all sessions for a user. Returns count of deleted sessions."""
count = (
self.db.query(UserSession).filter(UserSession.user_id == user.id).delete()
)
self.db.commit()
return count
def cleanup_expired_sessions(self) -> int:
"""Delete all expired sessions. Returns count of deleted sessions."""
count = (
self.db.query(UserSession)
.filter(UserSession.expires_at < datetime.utcnow())
.delete()
)
self.db.commit()
return count
# --- API Key Operations ---
def create_api_key(
self,
user: User,
name: str,
description: Optional[str] = None,
scopes: Optional[list[str]] = None,
expires_at: Optional[datetime] = None,
) -> tuple[APIKey, str]:
"""Create a new API key for a user.
Returns a tuple of (api_key, key) where key is the plaintext
API key that should be sent to the client. The key is only returned
once and should be stored securely by the user.
"""
key = generate_api_key()
key_hash = hash_token(key)
api_key = APIKey(
key_hash=key_hash,
name=name,
user_id=user.username, # Legacy field
owner_id=user.id,
description=description,
scopes=scopes or ["read", "write"],
expires_at=expires_at,
)
self.db.add(api_key)
self.db.commit()
self.db.refresh(api_key)
return api_key, key
def get_api_key_by_key(self, key: str) -> Optional[APIKey]:
"""Get an API key by its plaintext key.
Returns None if the key doesn't exist or has expired.
"""
if not key.startswith(API_KEY_PREFIX):
return None
key_hash = hash_token(key)
api_key = self.db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if not api_key:
return None
# Check expiration
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
return None
# Update last used time
api_key.last_used = datetime.utcnow()
self.db.commit()
return api_key
def get_api_key_by_id(self, key_id: str) -> Optional[APIKey]:
"""Get an API key by its ID."""
return self.db.query(APIKey).filter(APIKey.id == key_id).first()
def list_user_api_keys(self, user: User) -> list[APIKey]:
"""List all API keys for a user."""
return (
self.db.query(APIKey)
.filter(APIKey.owner_id == user.id)
.order_by(APIKey.created_at.desc())
.all()
)
def delete_api_key(self, api_key: APIKey) -> None:
"""Delete an API key."""
self.db.delete(api_key)
self.db.commit()
def get_user_from_api_key(self, key: str) -> Optional[User]:
"""Get the user associated with an API key.
Returns None if the key is invalid or the user is inactive.
"""
api_key = self.get_api_key_by_key(key)
if not api_key:
return None
if not api_key.owner_id:
return None
user = self.db.query(User).filter(User.id == api_key.owner_id).first()
if not user or not user.is_active:
return None
return user
def create_default_admin(db: Session) -> Optional[User]:
"""Create the default admin user if no users exist.
Returns the created user, or None if users already exist.
"""
# Check if any users exist
user_count = db.query(User).count()
if user_count > 0:
return None
# Create default admin
auth_service = AuthService(db)
admin = auth_service.create_user(
username="admin",
password="admin",
is_admin=True,
must_change_password=True,
)
return admin
# --- FastAPI Dependencies ---
from fastapi import Depends, HTTPException, status, Cookie, Header
from .database import get_db
# Cookie name for session token
SESSION_COOKIE_NAME = "orchard_session"
def get_current_user_optional(
db: Session = Depends(get_db),
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
authorization: Optional[str] = Header(None),
) -> Optional[User]:
"""Get the current user from session cookie or API key.
Returns None if no valid authentication is provided.
Does not raise an exception for unauthenticated requests.
"""
auth_service = AuthService(db)
# First try session cookie (web UI)
if session_token:
session = auth_service.get_session_by_token(session_token)
if session:
user = auth_service.get_user_by_id(str(session.user_id))
if user and user.is_active:
return user
# Then try API key (CLI/programmatic access)
if authorization:
if authorization.startswith("Bearer "):
api_key = authorization[7:] # Remove "Bearer " prefix
user = auth_service.get_user_from_api_key(api_key)
if user:
return user
return None
def get_current_user(
user: Optional[User] = Depends(get_current_user_optional),
) -> User:
"""Get the current authenticated user.
Raises HTTPException 401 if not authenticated.
"""
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_admin(
user: User = Depends(get_current_user),
) -> User:
"""Require the current user to be an admin.
Raises HTTPException 403 if user is not an admin.
"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
return user
def get_auth_service(db: Session = Depends(get_db)) -> AuthService:
"""Get an AuthService instance."""
return AuthService(db)

View File

@@ -9,6 +9,7 @@ from .config import get_settings
from .database import init_db, SessionLocal
from .routes import router
from .seed import seed_database
from .auth import create_default_admin
settings = get_settings()
logging.basicConfig(level=logging.INFO)
@@ -20,6 +21,18 @@ async def lifespan(app: FastAPI):
# Startup: initialize database
init_db()
# Create default admin user if no users exist
db = SessionLocal()
try:
admin = create_default_admin(db)
if admin:
logger.warning(
"Default admin user created with username 'admin' and password 'admin'. "
"CHANGE THIS PASSWORD IMMEDIATELY!"
)
finally:
db.close()
# Seed test data in development mode
if settings.is_development:
logger.info(f"Running in {settings.env} mode - checking for seed data")
@@ -48,7 +61,11 @@ app.include_router(router)
# Serve static files (React build) if the directory exists
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
if os.path.exists(static_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
app.mount(
"/assets",
StaticFiles(directory=os.path.join(static_dir, "assets")),
name="assets",
)
@app.get("/")
async def serve_spa():
@@ -60,6 +77,7 @@ if os.path.exists(static_dir):
# Don't catch API routes or health endpoint
if full_path.startswith("api/") or full_path.startswith("health"):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")
# Serve SPA for all other routes (including /project/*)
@@ -68,4 +86,5 @@ if os.path.exists(static_dir):
return FileResponse(index_path)
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -11,6 +11,7 @@ from sqlalchemy import (
CheckConstraint,
Index,
JSON,
ARRAY,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, declarative_base
@@ -302,20 +303,104 @@ class AccessPermission(Base):
)
class User(Base):
"""User account for authentication."""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255)) # NULL if OIDC-only user
email = Column(String(255))
is_admin = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=False)
oidc_subject = Column(String(255)) # OIDC subject claim
oidc_issuer = Column(String(512)) # OIDC issuer URL
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
)
last_login = Column(DateTime(timezone=True))
# Relationships
api_keys = relationship(
"APIKey", back_populates="owner", cascade="all, delete-orphan"
)
sessions = relationship(
"Session", back_populates="user", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_email", "email"),
Index("idx_users_oidc_subject", "oidc_subject"),
)
class Session(Base):
"""User session for web login."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token_hash = Column(String(64), unique=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True), nullable=False)
last_accessed = Column(DateTime(timezone=True), default=datetime.utcnow)
user_agent = Column(String(512))
ip_address = Column(String(45))
user = relationship("User", back_populates="sessions")
__table_args__ = (
Index("idx_sessions_user_id", "user_id"),
Index("idx_sessions_token_hash", "token_hash"),
Index("idx_sessions_expires_at", "expires_at"),
)
class AuthSettings(Base):
"""Authentication settings for OIDC configuration."""
__tablename__ = "auth_settings"
key = Column(String(255), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class APIKey(Base):
__tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key_hash = Column(String(64), unique=True, nullable=False)
name = Column(String(255), nullable=False)
user_id = Column(String(255), nullable=False)
user_id = Column(
String(255), nullable=False
) # Legacy field, kept for compatibility
owner_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True, # Nullable for migration compatibility
)
description = Column(Text)
scopes = Column(ARRAY(String), default=["read", "write"])
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True))
last_used = Column(DateTime(timezone=True))
owner = relationship("User", back_populates="api_keys")
__table_args__ = (
Index("idx_api_keys_user_id", "user_id"),
Index("idx_api_keys_key_hash", "key_hash"),
Index("idx_api_keys_owner_id", "owner_id"),
)

View File

@@ -11,6 +11,8 @@ from fastapi import (
Query,
Header,
Response,
Cookie,
status,
)
from fastapi.responses import StreamingResponse, RedirectResponse
from sqlalchemy.orm import Session
@@ -42,6 +44,7 @@ from .models import (
UploadLock,
Consumer,
AuditLog,
User,
)
from .schemas import (
ProjectCreate,
@@ -94,6 +97,16 @@ from .schemas import (
StatsReportResponse,
GlobalArtifactResponse,
GlobalTagResponse,
LoginRequest,
LoginResponse,
ChangePasswordRequest,
UserResponse,
UserCreate,
UserUpdate,
ResetPasswordRequest,
APIKeyCreate,
APIKeyResponse,
APIKeyCreateResponse,
)
from .metadata import extract_metadata
from .config import get_settings
@@ -118,14 +131,39 @@ def sanitize_filename(filename: str) -> str:
return re.sub(r'[\r\n"]', "", filename)
def get_user_id_from_request(
request: Request,
db: Session,
current_user: Optional[User] = None,
) -> str:
"""Extract user ID from request using auth system.
If a current_user is provided (from auth dependency), use their username.
Otherwise, try to authenticate from headers and fall back to 'anonymous'.
"""
if current_user:
return current_user.username
# Try to authenticate from API key header
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
api_key = auth_header[7:]
auth_service = AuthService(db)
user = auth_service.get_user_from_api_key(api_key)
if user:
return user.username
return "anonymous"
def get_user_id(request: Request) -> str:
"""Extract user ID from request (simplified for now)"""
api_key = request.headers.get("X-Orchard-API-Key")
if api_key:
return "api-user"
"""Legacy function for backward compatibility.
DEPRECATED: Use get_user_id_from_request with db session for proper auth.
"""
auth = request.headers.get("Authorization")
if auth:
return "bearer-user"
if auth and auth.startswith("Bearer "):
return "authenticated-user"
return "anonymous"
@@ -320,6 +358,460 @@ def health_check(
)
# --- Authentication Routes ---
from .auth import (
AuthService,
get_current_user,
get_current_user_optional,
require_admin,
get_auth_service,
SESSION_COOKIE_NAME,
verify_password,
)
@router.post("/api/v1/auth/login", response_model=LoginResponse)
def login(
login_request: LoginRequest,
request: Request,
response: Response,
auth_service: AuthService = Depends(get_auth_service),
):
"""
Login with username and password.
Returns user info and sets a session cookie.
"""
user = auth_service.authenticate_user(
login_request.username, login_request.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
# Create session
session, token = auth_service.create_session(
user,
user_agent=request.headers.get("User-Agent"),
ip_address=request.client.host if request.client else None,
)
# Update last login
auth_service.update_last_login(user)
# Set session cookie
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
max_age=24 * 60 * 60, # 24 hours
)
# Log audit
_log_audit(
auth_service.db,
"auth.login",
f"user:{user.username}",
user.username,
request,
{"user_id": str(user.id)},
)
return LoginResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
must_change_password=user.must_change_password,
)
@router.post("/api/v1/auth/logout")
def logout(
request: Request,
response: Response,
db: Session = Depends(get_db),
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
):
"""
Logout and invalidate the session.
"""
if session_token:
auth_service = AuthService(db)
session = auth_service.get_session_by_token(session_token)
if session:
auth_service.delete_session(session)
# Clear the session cookie
response.delete_cookie(key=SESSION_COOKIE_NAME)
return {"message": "Logged out successfully"}
@router.get("/api/v1/auth/me", response_model=UserResponse)
def get_current_user_info(
current_user: User = Depends(get_current_user),
):
"""
Get information about the currently authenticated user.
"""
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
is_active=current_user.is_active,
must_change_password=current_user.must_change_password,
created_at=current_user.created_at,
last_login=current_user.last_login,
)
@router.post("/api/v1/auth/change-password")
def change_password(
password_request: ChangePasswordRequest,
request: Request,
current_user: User = Depends(get_current_user),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Change the current user's password.
Requires the current password for verification.
"""
# Verify current password
if not verify_password(
password_request.current_password, current_user.password_hash
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
# Change password
auth_service.change_password(current_user, password_request.new_password)
# Log audit
_log_audit(
auth_service.db,
"auth.password_change",
f"user:{current_user.username}",
current_user.username,
request,
)
return {"message": "Password changed successfully"}
# --- API Key Routes ---
@router.post("/api/v1/auth/keys", response_model=APIKeyCreateResponse)
def create_api_key(
key_request: APIKeyCreate,
request: Request,
current_user: User = Depends(get_current_user),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Create a new API key for the current user.
The key is only returned once - store it securely!
"""
api_key, key = auth_service.create_api_key(
user=current_user,
name=key_request.name,
description=key_request.description,
scopes=key_request.scopes,
)
# Log audit
_log_audit(
auth_service.db,
"auth.api_key_create",
f"api_key:{api_key.id}",
current_user.username,
request,
{"key_name": key_request.name},
)
return APIKeyCreateResponse(
id=api_key.id,
name=api_key.name,
description=api_key.description,
scopes=api_key.scopes,
key=key,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
)
@router.get("/api/v1/auth/keys", response_model=List[APIKeyResponse])
def list_api_keys(
current_user: User = Depends(get_current_user),
auth_service: AuthService = Depends(get_auth_service),
):
"""
List all API keys for the current user.
Does not include the secret key.
"""
keys = auth_service.list_user_api_keys(current_user)
return [
APIKeyResponse(
id=k.id,
name=k.name,
description=k.description,
scopes=k.scopes,
created_at=k.created_at,
expires_at=k.expires_at,
last_used=k.last_used,
)
for k in keys
]
@router.delete("/api/v1/auth/keys/{key_id}")
def delete_api_key(
key_id: str,
request: Request,
current_user: User = Depends(get_current_user),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Revoke an API key.
Users can only delete their own keys, unless they are an admin.
"""
api_key = auth_service.get_api_key_by_id(key_id)
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
# Check ownership (admins can delete any key)
if api_key.owner_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete another user's API key",
)
key_name = api_key.name
auth_service.delete_api_key(api_key)
# Log audit
_log_audit(
auth_service.db,
"auth.api_key_delete",
f"api_key:{key_id}",
current_user.username,
request,
{"key_name": key_name},
)
return {"message": "API key deleted successfully"}
# --- Admin User Management Routes ---
@router.get("/api/v1/admin/users", response_model=List[UserResponse])
def list_users(
include_inactive: bool = Query(default=False),
current_user: User = Depends(require_admin),
auth_service: AuthService = Depends(get_auth_service),
):
"""
List all users (admin only).
"""
users = auth_service.list_users(include_inactive=include_inactive)
return [
UserResponse(
id=u.id,
username=u.username,
email=u.email,
is_admin=u.is_admin,
is_active=u.is_active,
must_change_password=u.must_change_password,
created_at=u.created_at,
last_login=u.last_login,
)
for u in users
]
@router.post("/api/v1/admin/users", response_model=UserResponse)
def create_user(
user_create: UserCreate,
request: Request,
current_user: User = Depends(require_admin),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Create a new user (admin only).
"""
# Check if username already exists
existing = auth_service.get_user_by_username(user_create.username)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists",
)
user = auth_service.create_user(
username=user_create.username,
password=user_create.password,
email=user_create.email,
is_admin=user_create.is_admin,
)
# Log audit
_log_audit(
auth_service.db,
"admin.user_create",
f"user:{user.username}",
current_user.username,
request,
{"new_user": user_create.username, "is_admin": user_create.is_admin},
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
is_active=user.is_active,
must_change_password=user.must_change_password,
created_at=user.created_at,
last_login=user.last_login,
)
@router.get("/api/v1/admin/users/{username}", response_model=UserResponse)
def get_user(
username: str,
current_user: User = Depends(require_admin),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Get a specific user by username (admin only).
"""
user = auth_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
is_active=user.is_active,
must_change_password=user.must_change_password,
created_at=user.created_at,
last_login=user.last_login,
)
@router.put("/api/v1/admin/users/{username}", response_model=UserResponse)
def update_user(
username: str,
user_update: UserUpdate,
request: Request,
current_user: User = Depends(require_admin),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Update a user (admin only).
"""
user = auth_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent removing the last admin
if user_update.is_admin is False and user.is_admin:
admin_count = (
auth_service.db.query(User)
.filter(User.is_admin == True, User.is_active == True)
.count()
)
if admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove the last admin",
)
# Update fields
if user_update.email is not None:
user.email = user_update.email
if user_update.is_admin is not None:
user.is_admin = user_update.is_admin
if user_update.is_active is not None:
user.is_active = user_update.is_active
auth_service.db.commit()
# Log audit
_log_audit(
auth_service.db,
"admin.user_update",
f"user:{username}",
current_user.username,
request,
{"updates": user_update.model_dump(exclude_none=True)},
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
is_active=user.is_active,
must_change_password=user.must_change_password,
created_at=user.created_at,
last_login=user.last_login,
)
@router.post("/api/v1/admin/users/{username}/reset-password")
def reset_user_password(
username: str,
reset_request: ResetPasswordRequest,
request: Request,
current_user: User = Depends(require_admin),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Reset a user's password (admin only).
Sets must_change_password to True.
"""
user = auth_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
auth_service.reset_user_password(user, reset_request.new_password)
# Log audit
_log_audit(
auth_service.db,
"admin.password_reset",
f"user:{username}",
current_user.username,
request,
)
return {"message": f"Password reset for user {username}"}
# Global search
@router.get("/api/v1/search", response_model=GlobalSearchResponse)
def global_search(
@@ -513,9 +1005,12 @@ def list_projects(
@router.post("/api/v1/projects", response_model=ProjectResponse)
def create_project(
project: ProjectCreate, request: Request, db: Session = Depends(get_db)
project: ProjectCreate,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
user_id = get_user_id(request)
user_id = get_user_id_from_request(request, db, current_user)
existing = db.query(Project).filter(Project.name == project.name).first()
if existing:
@@ -1150,6 +1645,7 @@ def upload_artifact(
content_length: Optional[int] = Header(None, alias="Content-Length"),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
client_checksum: Optional[str] = Header(None, alias="X-Checksum-SHA256"),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Upload an artifact to a package.
@@ -1157,9 +1653,10 @@ def upload_artifact(
Headers:
- X-Checksum-SHA256: Optional client-provided SHA256 for verification
- User-Agent: Captured for audit purposes
- Authorization: Bearer <api-key> for authentication
"""
start_time = time.time()
user_id = get_user_id(request)
user_id = get_user_id_from_request(request, db, current_user)
settings = get_settings()
storage_result = None

View File

@@ -686,3 +686,93 @@ class StatsReportResponse(BaseModel):
format: str # "json", "csv", "markdown"
generated_at: datetime
content: str # The report content
# Authentication schemas
class LoginRequest(BaseModel):
"""Login request with username and password"""
username: str
password: str
class LoginResponse(BaseModel):
"""Login response with user info"""
id: UUID
username: str
email: Optional[str]
is_admin: bool
must_change_password: bool
class ChangePasswordRequest(BaseModel):
"""Change password request"""
current_password: str
new_password: str
class UserResponse(BaseModel):
"""User information response"""
id: UUID
username: str
email: Optional[str]
is_admin: bool
is_active: bool
must_change_password: bool
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True
class UserCreate(BaseModel):
"""Create user request (admin only)"""
username: str
password: str
email: Optional[str] = None
is_admin: bool = False
class UserUpdate(BaseModel):
"""Update user request (admin only)"""
email: Optional[str] = None
is_admin: Optional[bool] = None
is_active: Optional[bool] = None
class ResetPasswordRequest(BaseModel):
"""Reset password request (admin only)"""
new_password: str
class APIKeyCreate(BaseModel):
"""Create API key request"""
name: str
description: Optional[str] = None
scopes: Optional[List[str]] = None
class APIKeyResponse(BaseModel):
"""API key response (without the secret key)"""
id: UUID
name: str
description: Optional[str]
scopes: Optional[List[str]]
created_at: datetime
expires_at: Optional[datetime]
last_used: Optional[datetime]
class Config:
from_attributes = True
class APIKeyCreateResponse(BaseModel):
"""API key creation response (includes the secret key - only shown once)"""
id: UUID
name: str
description: Optional[str]
scopes: Optional[List[str]]
key: str # The actual API key - only returned on creation
created_at: datetime
expires_at: Optional[datetime]