Implement authentication system with access control UI

This commit is contained in:
Mondo Diaz
2026-01-12 10:52:35 -06:00
committed by Dane Moss
parent 1cbd335443
commit 617bcbe89c
39 changed files with 8561 additions and 116 deletions

1208
backend/app/auth.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 ""

View File

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

View File

@@ -1,14 +1,19 @@
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
import logging
import os
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
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
from .rate_limit import limiter
settings = get_settings()
logging.basicConfig(level=logging.INFO)
@@ -20,6 +25,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 'changeme123'. "
"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")
@@ -42,13 +59,21 @@ app = FastAPI(
lifespan=lifespan,
)
# Set up rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Include API routes
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 +85,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 +94,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"),
)

16
backend/app/rate_limit.py Normal file
View File

@@ -0,0 +1,16 @@
"""Rate limiting configuration for Orchard API.
Uses slowapi for rate limiting with IP-based keys.
"""
import os
from slowapi import Limiter
from slowapi.util import get_remote_address
# Rate limiter - uses IP address as key
limiter = Limiter(key_func=get_remote_address)
# Rate limit strings - configurable via environment for testing
# Default: 5 login attempts per minute per IP
# In tests: set ORCHARD_LOGIN_RATE_LIMIT to a high value like "1000/minute"
LOGIN_RATE_LIMIT = os.environ.get("ORCHARD_LOGIN_RATE_LIMIT", "5/minute")

File diff suppressed because it is too large Load Diff

View File

@@ -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",
@@ -686,3 +693,173 @@ 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]
# OIDC Configuration schemas
class OIDCConfigResponse(BaseModel):
"""OIDC configuration response (hides client secret)"""
enabled: bool
issuer_url: str
client_id: str
has_client_secret: bool # True if secret is configured, but don't expose it
scopes: List[str]
auto_create_users: bool
admin_group: str
class OIDCConfigUpdate(BaseModel):
"""Update OIDC configuration"""
enabled: Optional[bool] = None
issuer_url: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None # Only set if changing
scopes: Optional[List[str]] = None
auto_create_users: Optional[bool] = None
admin_group: Optional[str] = None
class OIDCStatusResponse(BaseModel):
"""Public OIDC status response"""
enabled: bool
issuer_url: Optional[str] = None # Only included if enabled
class OIDCLoginResponse(BaseModel):
"""OIDC login initiation response"""
authorization_url: str
# Access Permission schemas
class AccessPermissionCreate(BaseModel):
"""Grant access to a user for a project"""
username: str
level: str # 'read', 'write', or 'admin'
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionUpdate(BaseModel):
"""Update access permission"""
level: Optional[str] = None
expires_at: Optional[datetime] = None
@field_validator('level')
@classmethod
def validate_level(cls, v):
if v is not None and v not in ('read', 'write', 'admin'):
raise ValueError("level must be 'read', 'write', or 'admin'")
return v
class AccessPermissionResponse(BaseModel):
"""Access permission response"""
id: UUID
project_id: UUID
user_id: str
level: str
created_at: datetime
expires_at: Optional[datetime]
class Config:
from_attributes = True
class ProjectWithAccessResponse(ProjectResponse):
"""Project response with user's access level"""
user_access_level: Optional[str] = None