From 6b9f63a30ece7337ce04f6cb980493e837a86d61 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Thu, 8 Jan 2026 18:52:57 -0600 Subject: [PATCH] Add frontend access control enhancements and JWT support - Hide New Project button for unauthenticated users, show login link - Add lock icon for private projects on home page - Show access level badges on project cards (Owner, Admin, Write, Read) - Add permission expiration date field to AccessManagement component - Add query timeout configuration for database (ORCHARD_DATABASE_QUERY_TIMEOUT) - Add JWT token validation support for external identity providers - Configurable via ORCHARD_JWT_* environment variables - Supports HS256 with secret or RS256 with JWKS - Auto-provisions users from JWT claims --- backend/app/auth.py | 175 ++++++++++++++++++- backend/app/config.py | 12 ++ backend/app/database.py | 7 + backend/app/routes.py | 52 +++++- backend/app/schemas.py | 7 + frontend/src/components/AccessManagement.css | 16 ++ frontend/src/components/AccessManagement.tsx | 50 +++++- frontend/src/pages/Home.css | 13 ++ frontend/src/pages/Home.tsx | 52 +++++- frontend/src/types.ts | 10 +- 10 files changed, 373 insertions(+), 21 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index b75da58..116227d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,16 +1,20 @@ """Authentication service for Orchard. -Handles password hashing, session management, and API key operations. +Handles password hashing, session management, API key operations, and JWT validation. """ import hashlib import secrets +import logging from datetime import datetime, timedelta, timezone from typing import Optional from passlib.context import CryptContext from sqlalchemy.orm import Session from .models import User, Session as UserSession, APIKey +from .config import get_settings + +logger = logging.getLogger(__name__) # Password hashing context (bcrypt with cost factor 12) @@ -374,6 +378,147 @@ def create_default_admin(db: Session) -> Optional[User]: return admin +# --- JWT Validation --- + + +def validate_jwt_token(token: str) -> Optional[dict]: + """Validate a JWT token and return the decoded payload. + + Returns None if validation fails or JWT is not configured. + Uses python-jose for JWT operations. + """ + settings = get_settings() + + if not settings.jwt_enabled: + return None + + try: + from jose import jwt, JWTError, ExpiredSignatureError + from jose.exceptions import JWTClaimsError + except ImportError: + logger.warning("python-jose not installed, JWT authentication disabled") + return None + + try: + # Build decode options + decode_options = {} + + # Set up key for validation + if settings.jwt_algorithm.startswith("RS"): + # RS256/RS384/RS512 - use JWKS + if not settings.jwt_jwks_url: + logger.error("JWT JWKS URL not configured for RSA algorithm") + return None + + try: + import httpx + + # Fetch JWKS from the URL + response = httpx.get(settings.jwt_jwks_url, timeout=10.0) + response.raise_for_status() + jwks = response.json() + + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + + # Find the matching key + rsa_key = None + for key in jwks.get("keys", []): + if key.get("kid") == kid: + rsa_key = key + break + + if not rsa_key: + logger.error(f"No matching key found in JWKS for kid: {kid}") + return None + + key = rsa_key + except Exception as e: + logger.error(f"Failed to get signing key from JWKS: {e}") + return None + else: + # HS256/HS384/HS512 - use secret + if not settings.jwt_secret: + logger.error("JWT secret not configured for HMAC algorithm") + return None + key = settings.jwt_secret + + # Build decode kwargs + decode_kwargs = { + "algorithms": [settings.jwt_algorithm], + "options": decode_options, + } + + # Add issuer validation if configured + if settings.jwt_issuer: + decode_kwargs["issuer"] = settings.jwt_issuer + + # Add audience validation if configured + if settings.jwt_audience: + decode_kwargs["audience"] = settings.jwt_audience + + # Decode and validate the token + payload = jwt.decode(token, key, **decode_kwargs) + return payload + + except ExpiredSignatureError: + logger.debug("JWT token expired") + return None + except JWTClaimsError as e: + logger.debug(f"JWT claims error: {e}") + return None + except JWTError as e: + logger.debug(f"Invalid JWT token: {e}") + return None + except Exception as e: + logger.error(f"JWT validation error: {e}") + return None + + +def get_or_create_user_from_jwt(db: Session, payload: dict) -> Optional[User]: + """Get or create a user from JWT payload. + + Uses the configured username claim to extract the username. + Creates a new user if one doesn't exist (for SSO auto-provisioning). + """ + settings = get_settings() + username = payload.get(settings.jwt_username_claim) + + if not username: + logger.warning(f"JWT missing username claim: {settings.jwt_username_claim}") + return None + + # Sanitize username (remove domain from email if needed) + if "@" in username and settings.jwt_username_claim == "email": + # Keep full email as username for email-based auth + pass + + auth_service = AuthService(db) + user = auth_service.get_user_by_username(username) + + if user: + if not user.is_active: + logger.debug(f"JWT user {username} is inactive") + return None + return user + + # Auto-provision user from JWT + logger.info(f"Auto-provisioning user from JWT: {username}") + try: + user = auth_service.create_user( + username=username, + password=None, # No password for SSO users + email=payload.get("email"), + is_admin=False, + must_change_password=False, + ) + return user + except Exception as e: + logger.error(f"Failed to auto-provision JWT user: {e}") + return None + + # --- FastAPI Dependencies --- from fastapi import Depends, HTTPException, status, Cookie, Header @@ -388,10 +533,15 @@ def get_current_user_optional( 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. + """Get the current user from session cookie, API key, or JWT token. Returns None if no valid authentication is provided. Does not raise an exception for unauthenticated requests. + + Authentication methods are tried in order: + 1. Session cookie (web UI) + 2. API key (Bearer token starting with 'orch_') + 3. JWT token (Bearer token that's a valid JWT) """ auth_service = AuthService(db) @@ -403,13 +553,24 @@ def get_current_user_optional( 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) + # Then try Bearer token (API key or JWT) + if authorization and authorization.startswith("Bearer "): + token = authorization[7:] # Remove "Bearer " prefix + + # Check if it's an API key (starts with orch_) + if token.startswith(API_KEY_PREFIX): + user = auth_service.get_user_from_api_key(token) if user: return user + else: + # Try JWT validation + settings = get_settings() + if settings.jwt_enabled: + payload = validate_jwt_token(token) + if payload: + user = get_or_create_user_from_jwt(db, payload) + if user: + return user return None diff --git a/backend/app/config.py b/backend/app/config.py index fa78674..8a19d89 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): database_pool_recycle: int = ( 1800 # Recycle connections after this many seconds (30 min) ) + database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout) # S3 s3_endpoint: str = "" @@ -52,6 +53,17 @@ class Settings(BaseSettings): log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL log_format: str = "auto" # "json", "standard", or "auto" (json in production) + # JWT Authentication settings (optional, for external identity providers) + jwt_enabled: bool = False # Enable JWT token validation + jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS + jwt_algorithm: str = "HS256" # HS256 or RS256 + jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation + jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation + jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json) + jwt_username_claim: str = ( + "sub" # JWT claim to use as username (sub, email, preferred_username, etc.) + ) + @property def database_url(self) -> str: sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else "" diff --git a/backend/app/database.py b/backend/app/database.py index 25a4cc0..17f7054 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -12,6 +12,12 @@ from .models import Base settings = get_settings() logger = logging.getLogger(__name__) +# Build connect_args with query timeout if configured +connect_args = {} +if settings.database_query_timeout > 0: + # PostgreSQL statement_timeout is in milliseconds + connect_args["options"] = f"-c statement_timeout={settings.database_query_timeout * 1000}" + # Create engine with connection pool configuration engine = create_engine( settings.database_url, @@ -21,6 +27,7 @@ engine = create_engine( max_overflow=settings.database_max_overflow, pool_timeout=settings.database_pool_timeout, pool_recycle=settings.database_pool_recycle, + connect_args=connect_args, ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/routes.py b/backend/app/routes.py index 27ccb81..ccb2cf2 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -45,11 +45,13 @@ from .models import ( Consumer, AuditLog, User, + AccessPermission, ) from .schemas import ( ProjectCreate, ProjectUpdate, ProjectResponse, + ProjectWithAccessResponse, PackageCreate, PackageUpdate, PackageResponse, @@ -947,7 +949,7 @@ def global_search( # Project routes -@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse]) +@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectWithAccessResponse]) def list_projects( request: Request, page: int = Query(default=1, ge=1, description="Page number"), @@ -963,8 +965,9 @@ def list_projects( ), order: str = Query(default="asc", description="Sort order (asc, desc)"), db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional), ): - user_id = get_user_id(request) + user_id = current_user.username if current_user else get_user_id(request) # Validate sort field valid_sort_fields = { @@ -1022,8 +1025,51 @@ def list_projects( # Calculate total pages total_pages = math.ceil(total / limit) if total > 0 else 1 + # Build access level info for each project + project_ids = [p.id for p in projects] + access_map = {} + + if current_user and project_ids: + # Get access permissions for this user across these projects + permissions = ( + db.query(AccessPermission) + .filter( + AccessPermission.project_id.in_(project_ids), + AccessPermission.user_id == current_user.username, + ) + .all() + ) + access_map = {p.project_id: p.level for p in permissions} + + # Build response with access levels + items = [] + for p in projects: + is_owner = p.created_by == user_id + access_level = None + + if is_owner: + access_level = "admin" + elif p.id in access_map: + access_level = access_map[p.id] + elif p.is_public: + access_level = "read" + + items.append( + ProjectWithAccessResponse( + id=p.id, + name=p.name, + description=p.description, + is_public=p.is_public, + created_at=p.created_at, + updated_at=p.updated_at, + created_by=p.created_by, + access_level=access_level, + is_owner=is_owner, + ) + ) + return PaginatedResponse( - items=projects, + items=items, pagination=PaginationMeta( page=page, limit=limit, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index dd9ef13..b81b027 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel): is_public: Optional[bool] = None +class ProjectWithAccessResponse(ProjectResponse): + """Project response with user's access level included""" + + access_level: Optional[str] = None # 'read', 'write', 'admin', or None + is_owner: bool = False + + # Package format and platform enums PACKAGE_FORMATS = [ "generic", diff --git a/frontend/src/components/AccessManagement.css b/frontend/src/components/AccessManagement.css index 25a1dfb..21c8d5d 100644 --- a/frontend/src/components/AccessManagement.css +++ b/frontend/src/components/AccessManagement.css @@ -98,3 +98,19 @@ .btn-danger:hover { background: #c0392b; } + +/* Expired permission styling */ +.expired { + color: var(--color-error); + font-weight: 500; +} + +/* Date input styling in table */ +.access-table input[type="date"] { + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 4px; + font-size: 0.875rem; + color: var(--text-primary); +} diff --git a/frontend/src/components/AccessManagement.tsx b/frontend/src/components/AccessManagement.tsx index 5c71f88..6201661 100644 --- a/frontend/src/components/AccessManagement.tsx +++ b/frontend/src/components/AccessManagement.tsx @@ -22,11 +22,13 @@ export function AccessManagement({ projectName }: AccessManagementProps) { const [showAddForm, setShowAddForm] = useState(false); const [newUsername, setNewUsername] = useState(''); const [newLevel, setNewLevel] = useState('read'); + const [newExpiresAt, setNewExpiresAt] = useState(''); const [submitting, setSubmitting] = useState(false); // Edit state const [editingUser, setEditingUser] = useState(null); const [editLevel, setEditLevel] = useState('read'); + const [editExpiresAt, setEditExpiresAt] = useState(''); const loadPermissions = useCallback(async () => { try { @@ -55,10 +57,12 @@ export function AccessManagement({ projectName }: AccessManagementProps) { await grantProjectAccess(projectName, { username: newUsername.trim(), level: newLevel, + expires_at: newExpiresAt || undefined, }); setSuccess(`Access granted to ${newUsername}`); setNewUsername(''); setNewLevel('read'); + setNewExpiresAt(''); setShowAddForm(false); await loadPermissions(); setTimeout(() => setSuccess(null), 3000); @@ -73,7 +77,10 @@ export function AccessManagement({ projectName }: AccessManagementProps) { try { setSubmitting(true); setError(null); - await updateProjectAccess(projectName, username, { level: editLevel }); + await updateProjectAccess(projectName, username, { + level: editLevel, + expires_at: editExpiresAt || null, + }); setSuccess(`Updated access for ${username}`); setEditingUser(null); await loadPermissions(); @@ -105,10 +112,26 @@ export function AccessManagement({ projectName }: AccessManagementProps) { const startEdit = (permission: AccessPermission) => { setEditingUser(permission.user_id); setEditLevel(permission.level as AccessLevel); + // Convert ISO date to local date format for date input + setEditExpiresAt(permission.expires_at ? permission.expires_at.split('T')[0] : ''); }; const cancelEdit = () => { setEditingUser(null); + setEditExpiresAt(''); + }; + + const formatExpiration = (expiresAt: string | null) => { + if (!expiresAt) return 'Never'; + const date = new Date(expiresAt); + const now = new Date(); + const isExpired = date < now; + return ( + + {date.toLocaleDateString()} + {isExpired && ' (Expired)'} + + ); }; if (loading) { @@ -158,6 +181,17 @@ export function AccessManagement({ projectName }: AccessManagementProps) { +
+ + setNewExpiresAt(e.target.value)} + disabled={submitting} + min={new Date().toISOString().split('T')[0]} + /> +
@@ -175,6 +209,7 @@ export function AccessManagement({ projectName }: AccessManagementProps) { User Access Level Granted + Expires Actions @@ -200,6 +235,19 @@ export function AccessManagement({ projectName }: AccessManagementProps) { )} {new Date(p.created_at).toLocaleDateString()} + + {editingUser === p.user_id ? ( + setEditExpiresAt(e.target.value)} + disabled={submitting} + min={new Date().toISOString().split('T')[0]} + /> + ) : ( + formatExpiration(p.expires_at) + )} + {editingUser === p.user_id ? ( <> diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css index 3bde2bb..f5891d8 100644 --- a/frontend/src/pages/Home.css +++ b/frontend/src/pages/Home.css @@ -474,3 +474,16 @@ margin-top: 4px; font-size: 0.9375rem; } + +/* Lock icon for private projects */ +.lock-icon { + color: var(--warning); + flex-shrink: 0; +} + +/* Project badges container */ +.project-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c409b23..6d45faf 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterDropdown, FilterOption } from '../components/FilterDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { Pagination } from '../components/Pagination'; +import { useAuth } from '../contexts/AuthContext'; import './Home.css'; +// Lock icon SVG component +function LockIcon() { + return ( + + + + + ); +} + const SORT_OPTIONS: SortOption[] = [ { value: 'name', label: 'Name' }, { value: 'created_at', label: 'Created' }, @@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [ function Home() { const [searchParams, setSearchParams] = useSearchParams(); + const { user } = useAuth(); const [projectsData, setProjectsData] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -117,9 +129,15 @@ function Home() {

Projects

- + {user ? ( + + ) : ( + + Login to create projects + + )}
{error &&
{error}
} @@ -199,12 +217,32 @@ function Home() {
{projects.map((project) => ( -

{project.name}

+

+ {!project.is_public && } + {project.name} +

{project.description &&

{project.description}

}
- - {project.is_public ? 'Public' : 'Private'} - +
+ + {project.is_public ? 'Public' : 'Private'} + + {user && project.access_level && ( + + {project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)} + + )} +
Created {new Date(project.created_at).toLocaleDateString()} {project.updated_at !== project.created_at && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 84d6fdd..e020e1a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,6 @@ +// Access Control types (moved to top for use in Project interface) +export type AccessLevel = 'read' | 'write' | 'admin'; + export interface Project { id: string; name: string; @@ -6,6 +9,9 @@ export interface Project { created_at: string; updated_at: string; created_by: string; + // Access level info (populated when listing projects) + access_level?: AccessLevel | null; + is_owner?: boolean; } export interface TagSummary { @@ -290,9 +296,7 @@ export interface UserUpdate { is_active?: boolean; } -// Access Control types -export type AccessLevel = 'read' | 'write' | 'admin'; - +// Access Permission types export interface AccessPermission { id: string; project_id: string;