mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 14:00:20 -05:00
Complete backend infrastructure and authentication system
Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
This commit is contained in:
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@db:5432/velocicompanion
|
||||
SECRET_KEY=your-secret-key-change-in-production-min-32-chars-long
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Run migrations and start server
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]
|
||||
147
backend/alembic.ini
Normal file
147
backend/alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url is configured in env.py from app.core.config
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
95
backend/alembic/env.py
Normal file
95
backend/alembic/env.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add app directory to Python path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Import models and database
|
||||
from app.core.database import Base
|
||||
from app.core.config import settings
|
||||
|
||||
# Import all models to ensure they're registered with Base
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.host import Host
|
||||
from app.models.case import Case
|
||||
from app.models.artifact import Artifact
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Set the database URL from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
114
backend/alembic/versions/f82b3092d056_initial_migration.py
Normal file
114
backend/alembic/versions/f82b3092d056_initial_migration.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: f82b3092d056
|
||||
Revises:
|
||||
Create Date: 2025-12-09 14:25:47.222289
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f82b3092d056'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# Create tenants table
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tenants_name'), 'tenants', ['name'], unique=True)
|
||||
|
||||
# Create users table
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sa.String(), nullable=False),
|
||||
sa.Column('role', sa.String(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
|
||||
# Create hosts table
|
||||
op.create_table(
|
||||
'hosts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(), nullable=True),
|
||||
sa.Column('os', sa.String(), nullable=True),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('host_metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_seen', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_hosts_id'), 'hosts', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_hosts_hostname'), 'hosts', ['hostname'], unique=False)
|
||||
|
||||
# Create cases table
|
||||
op.create_table(
|
||||
'cases',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('severity', sa.String(), nullable=True),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_cases_id'), 'cases', ['id'], unique=False)
|
||||
|
||||
# Create artifacts table
|
||||
op.create_table(
|
||||
'artifacts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('artifact_type', sa.String(), nullable=False),
|
||||
sa.Column('value', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('case_id', sa.Integer(), nullable=True),
|
||||
sa.Column('artifact_metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_artifacts_id'), 'artifacts', ['id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_artifacts_id'), table_name='artifacts')
|
||||
op.drop_table('artifacts')
|
||||
op.drop_index(op.f('ix_cases_id'), table_name='cases')
|
||||
op.drop_table('cases')
|
||||
op.drop_index(op.f('ix_hosts_hostname'), table_name='hosts')
|
||||
op.drop_index(op.f('ix_hosts_id'), table_name='hosts')
|
||||
op.drop_table('hosts')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_tenants_name'), table_name='tenants')
|
||||
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
164
backend/app/api/routes/auth.py
Normal file
164
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.auth import Token, UserLogin, UserRegister
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserRegister,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Register a new user
|
||||
|
||||
Creates a new user with hashed password. If tenant_id is not provided,
|
||||
a default tenant is created or used.
|
||||
"""
|
||||
# Check if username already exists
|
||||
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered"
|
||||
)
|
||||
|
||||
# Handle tenant_id
|
||||
tenant_id = user_data.tenant_id
|
||||
if tenant_id is None:
|
||||
# Create or get default tenant
|
||||
default_tenant = db.query(Tenant).filter(Tenant.name == "default").first()
|
||||
if not default_tenant:
|
||||
default_tenant = Tenant(name="default", description="Default tenant")
|
||||
db.add(default_tenant)
|
||||
db.commit()
|
||||
db.refresh(default_tenant)
|
||||
tenant_id = default_tenant.id
|
||||
else:
|
||||
# Verify tenant exists
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
# Create new user with hashed password
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
password_hash=hashed_password,
|
||||
role=user_data.role,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Authenticate user and return JWT token
|
||||
|
||||
Uses OAuth2 password flow for compatibility with OpenAPI docs.
|
||||
"""
|
||||
# Find user by username
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Verify password
|
||||
if not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(
|
||||
data={
|
||||
"sub": user.id,
|
||||
"tenant_id": user.tenant_id,
|
||||
"role": user.role
|
||||
}
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def get_current_user_profile(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Get current user profile
|
||||
|
||||
Returns the profile of the authenticated user.
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserRead)
|
||||
async def update_current_user_profile(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user profile
|
||||
|
||||
Allows users to update their own profile information.
|
||||
"""
|
||||
# Update username if provided
|
||||
if user_update.username is not None:
|
||||
# Check if new username is already taken
|
||||
existing_user = db.query(User).filter(
|
||||
User.username == user_update.username,
|
||||
User.id != current_user.id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
current_user.username = user_update.username
|
||||
|
||||
# Update password if provided
|
||||
if user_update.password is not None:
|
||||
current_user.password_hash = get_password_hash(user_update.password)
|
||||
|
||||
# Users cannot change their own role through this endpoint
|
||||
# (admin users should use the admin endpoints in /users)
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
97
backend/app/api/routes/hosts.py
Normal file
97
backend/app/api/routes/hosts.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user, get_tenant_id
|
||||
from app.models.user import User
|
||||
from app.models.host import Host
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HostCreate(BaseModel):
|
||||
hostname: str
|
||||
ip_address: Optional[str] = None
|
||||
os: Optional[str] = None
|
||||
host_metadata: Optional[dict] = None
|
||||
|
||||
|
||||
class HostRead(BaseModel):
|
||||
id: int
|
||||
hostname: str
|
||||
ip_address: Optional[str] = None
|
||||
os: Optional[str] = None
|
||||
tenant_id: int
|
||||
host_metadata: Optional[dict] = None
|
||||
created_at: datetime
|
||||
last_seen: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[HostRead])
|
||||
async def list_hosts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List hosts scoped to user's tenant
|
||||
"""
|
||||
hosts = db.query(Host).filter(Host.tenant_id == tenant_id).offset(skip).limit(limit).all()
|
||||
return hosts
|
||||
|
||||
|
||||
@router.post("/", response_model=HostRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_host(
|
||||
host_data: HostCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new host
|
||||
"""
|
||||
new_host = Host(
|
||||
hostname=host_data.hostname,
|
||||
ip_address=host_data.ip_address,
|
||||
os=host_data.os,
|
||||
tenant_id=tenant_id,
|
||||
host_metadata=host_data.host_metadata
|
||||
)
|
||||
|
||||
db.add(new_host)
|
||||
db.commit()
|
||||
db.refresh(new_host)
|
||||
|
||||
return new_host
|
||||
|
||||
|
||||
@router.get("/{host_id}", response_model=HostRead)
|
||||
async def get_host(
|
||||
host_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get host by ID (scoped to tenant)
|
||||
"""
|
||||
host = db.query(Host).filter(
|
||||
Host.id == host_id,
|
||||
Host.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
if not host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Host not found"
|
||||
)
|
||||
|
||||
return host
|
||||
60
backend/app/api/routes/ingestion.py
Normal file
60
backend/app/api/routes/ingestion.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user, get_tenant_id
|
||||
from app.models.user import User
|
||||
from app.models.host import Host
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IngestionData(BaseModel):
|
||||
hostname: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class IngestionResponse(BaseModel):
|
||||
message: str
|
||||
host_id: int
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestionResponse)
|
||||
async def ingest_data(
|
||||
ingestion: IngestionData,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ingest data from Velociraptor
|
||||
|
||||
Creates or updates host information scoped to the user's tenant.
|
||||
"""
|
||||
# Find or create host
|
||||
host = db.query(Host).filter(
|
||||
Host.hostname == ingestion.hostname,
|
||||
Host.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
if host:
|
||||
# Update existing host
|
||||
host.host_metadata = ingestion.data
|
||||
else:
|
||||
# Create new host
|
||||
host = Host(
|
||||
hostname=ingestion.hostname,
|
||||
tenant_id=tenant_id,
|
||||
host_metadata=ingestion.data
|
||||
)
|
||||
db.add(host)
|
||||
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
|
||||
return {
|
||||
"message": "Data ingested successfully",
|
||||
"host_id": host.id
|
||||
}
|
||||
103
backend/app/api/routes/tenants.py
Normal file
103
backend/app/api/routes/tenants.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user, require_role
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str
|
||||
description: str = None
|
||||
|
||||
|
||||
class TenantRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TenantRead])
|
||||
async def list_tenants(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List tenants
|
||||
|
||||
Regular users can only see their own tenant.
|
||||
Admins can see all tenants (cross-tenant access).
|
||||
"""
|
||||
if current_user.role == "admin":
|
||||
# Admins can see all tenants
|
||||
tenants = db.query(Tenant).all()
|
||||
else:
|
||||
# Regular users only see their tenant
|
||||
tenants = db.query(Tenant).filter(Tenant.id == current_user.tenant_id).all()
|
||||
|
||||
return tenants
|
||||
|
||||
|
||||
@router.post("/", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant(
|
||||
tenant_data: TenantCreate,
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new tenant (admin only)
|
||||
"""
|
||||
# Check if tenant name already exists
|
||||
existing_tenant = db.query(Tenant).filter(Tenant.name == tenant_data.name).first()
|
||||
if existing_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant name already exists"
|
||||
)
|
||||
|
||||
new_tenant = Tenant(
|
||||
name=tenant_data.name,
|
||||
description=tenant_data.description
|
||||
)
|
||||
|
||||
db.add(new_tenant)
|
||||
db.commit()
|
||||
db.refresh(new_tenant)
|
||||
|
||||
return new_tenant
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantRead)
|
||||
async def get_tenant(
|
||||
tenant_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get tenant by ID
|
||||
|
||||
Users can only view their own tenant unless they are admin.
|
||||
"""
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
# Check permissions
|
||||
if current_user.role != "admin" and tenant.id != current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to view this tenant"
|
||||
)
|
||||
|
||||
return tenant
|
||||
154
backend/app/api/routes/users.py
Normal file
154
backend/app/api/routes/users.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user, require_role
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRead, UserUpdate, UserCreate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserRead])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all users (admin only, scoped to tenant)
|
||||
|
||||
Admins can only see users within their own tenant unless they have
|
||||
cross-tenant access.
|
||||
"""
|
||||
# Scope to tenant
|
||||
query = db.query(User).filter(User.tenant_id == current_user.tenant_id)
|
||||
users = query.offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user by ID
|
||||
|
||||
Users can view their own profile or admins can view users in their tenant.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Check permissions: user can view themselves or admin can view users in their tenant
|
||||
if user.id != current_user.id and (
|
||||
current_user.role != "admin" or user.tenant_id != current_user.tenant_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to view this user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserRead)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update user (admin only)
|
||||
|
||||
Admins can update users within their tenant.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Check tenant access
|
||||
if user.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to update this user"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if user_update.username is not None:
|
||||
# Check if new username is already taken
|
||||
existing_user = db.query(User).filter(
|
||||
User.username == user_update.username,
|
||||
User.id != user_id
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
user.username = user_update.username
|
||||
|
||||
if user_update.password is not None:
|
||||
user.password_hash = get_password_hash(user_update.password)
|
||||
|
||||
if user_update.role is not None:
|
||||
user.role = user_update.role
|
||||
|
||||
if user_update.is_active is not None:
|
||||
user.is_active = user_update.is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Deactivate user (admin only)
|
||||
|
||||
Soft delete by setting is_active to False.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Check tenant access
|
||||
if user.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to delete this user"
|
||||
)
|
||||
|
||||
# Prevent self-deletion
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account"
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
user.is_active = False
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
40
backend/app/api/routes/vt.py
Normal file
40
backend/app/api/routes/vt.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VTLookupRequest(BaseModel):
|
||||
hash: str
|
||||
|
||||
|
||||
class VTLookupResponse(BaseModel):
|
||||
hash: str
|
||||
malicious: Optional[bool] = None
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/lookup", response_model=VTLookupResponse)
|
||||
async def virustotal_lookup(
|
||||
request: VTLookupRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Lookup hash in VirusTotal
|
||||
|
||||
Requires authentication. In a real implementation, this would call
|
||||
the VirusTotal API.
|
||||
"""
|
||||
# Placeholder implementation
|
||||
return {
|
||||
"hash": request.hash,
|
||||
"malicious": None,
|
||||
"message": "VirusTotal integration not yet implemented"
|
||||
}
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
16
backend/app/core/config.py
Normal file
16
backend/app/core/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
app_name: str = "VelociCompanion"
|
||||
database_url: str = "postgresql://postgres:postgres@db:5432/velocicompanion"
|
||||
secret_key: str = "your-secret-key-change-in-production-min-32-chars-long"
|
||||
access_token_expire_minutes: int = 30
|
||||
algorithm: str = "HS256"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
19
backend/app/core/database.py
Normal file
19
backend/app/core/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for getting database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
108
backend/app/core/deps.py
Normal file
108
backend/app/core/deps.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_token
|
||||
from app.models.user import User
|
||||
|
||||
# OAuth2 scheme for token extraction
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
Extract and validate JWT from Authorization header
|
||||
|
||||
Args:
|
||||
token: JWT token from Authorization header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Current user object
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user not found
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Verify and decode token
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id: Optional[int] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Get user from database
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
Ensure user is active
|
||||
|
||||
Args:
|
||||
current_user: Current user from get_current_user
|
||||
|
||||
Returns:
|
||||
Active user object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is inactive
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(allowed_roles: List[str]):
|
||||
"""
|
||||
Role-based access control dependency factory
|
||||
|
||||
Args:
|
||||
allowed_roles: List of roles that are allowed to access the endpoint
|
||||
|
||||
Returns:
|
||||
Dependency function that checks user role
|
||||
"""
|
||||
async def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
async def get_tenant_id(current_user: User = Depends(get_current_active_user)) -> int:
|
||||
"""
|
||||
Extract tenant_id from current user for scoping queries
|
||||
|
||||
Args:
|
||||
current_user: Current active user
|
||||
|
||||
Returns:
|
||||
Tenant ID
|
||||
"""
|
||||
return current_user.tenant_id
|
||||
58
backend/app/core/security.py
Normal file
58
backend/app/core/security.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password using bcrypt"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Create a JWT access token
|
||||
|
||||
Args:
|
||||
data: Dictionary containing user_id, tenant_id, role
|
||||
expires_delta: Optional expiration time delta
|
||||
|
||||
Returns:
|
||||
Encoded JWT token
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify and decode a JWT token
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded token payload or None if invalid
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
44
backend/app/main.py
Normal file
44
backend/app/main.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, users, tenants, hosts, ingestion, vt
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="Multi-tenant threat hunting companion for Velociraptor",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
app.include_router(tenants.router, prefix="/api/tenants", tags=["Tenants"])
|
||||
app.include_router(hosts.router, prefix="/api/hosts", tags=["Hosts"])
|
||||
app.include_router(ingestion.router, prefix="/api/ingestion", tags=["Ingestion"])
|
||||
app.include_router(vt.router, prefix="/api/vt", tags=["VirusTotal"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.app_name}",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
20
backend/app/models/artifact.py
Normal file
20
backend/app/models/artifact.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Artifact(Base):
|
||||
__tablename__ = "artifacts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
artifact_type = Column(String, nullable=False) # hash, ip, domain, email, etc.
|
||||
value = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
case_id = Column(Integer, ForeignKey("cases.id"), nullable=True)
|
||||
artifact_metadata = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
case = relationship("Case", back_populates="artifacts")
|
||||
22
backend/app/models/case.py
Normal file
22
backend/app/models/case.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String, default="open", nullable=False) # open, closed, investigating
|
||||
severity = Column(String, nullable=True) # low, medium, high, critical
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="cases")
|
||||
artifacts = relationship("Artifact", back_populates="case")
|
||||
21
backend/app/models/host.py
Normal file
21
backend/app/models/host.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Host(Base):
|
||||
__tablename__ = "hosts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
hostname = Column(String, index=True, nullable=False)
|
||||
ip_address = Column(String, nullable=True)
|
||||
os = Column(String, nullable=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
|
||||
host_metadata = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="hosts")
|
||||
19
backend/app/models/tenant.py
Normal file
19
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="tenant")
|
||||
hosts = relationship("Host", back_populates="tenant")
|
||||
cases = relationship("Case", back_populates="tenant")
|
||||
20
backend/app/models/user.py
Normal file
20
backend/app/models/user.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, default="user", nullable=False) # user, admin
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="users")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
29
backend/app/schemas/auth.py
Normal file
29
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token response schema"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token payload data"""
|
||||
user_id: Optional[int] = None
|
||||
tenant_id: Optional[int] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""User login request schema"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
"""User registration request schema"""
|
||||
username: str
|
||||
password: str
|
||||
tenant_id: Optional[int] = None
|
||||
role: str = "user"
|
||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema"""
|
||||
username: str
|
||||
role: str = "user"
|
||||
tenant_id: int
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a user"""
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating a user"""
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
"""Schema for reading user data (excludes password_hash)"""
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
alembic==1.13.1
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
Reference in New Issue
Block a user