Complete backend infrastructure and authentication system

Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-09 14:29:06 +00:00
parent af23e610b2
commit 961946026a
47 changed files with 2337 additions and 1 deletions

3
backend/.env.example Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

95
backend/alembic/env.py Normal file
View 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()

View 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"}

View 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
View File

View File

View File

View 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

View 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

View 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
}

View 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

View 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

View 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"
}

View File

View 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()

View 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
View 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

View 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
View 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"}

View File

View 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")

View 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")

View 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")

View 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")

View 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")

View File

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

View 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
View 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