Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,11 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Apply database migrations
env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/ci_db"
run: alembic upgrade head

- name: Run test suite with coverage
env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/ci_db"
Expand Down
15 changes: 11 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,22 +213,29 @@ Most list methods return an empty list on failure. Methods that fetch one resour
```bash
# Python 3.10+
pip install -r requirements.txt
# Installs Flask, Azure SDK clients, requests, psycopg2, PyJWT, and PyYAML for CI workflow validation.
# Installs Flask, Alembic, Azure SDK clients, requests, psycopg2, PyJWT, and PyYAML for CI workflow validation.

# Frontend
# The frontend directory is currently a scaffold. The React dashboard MVP is on the roadmap.

# API
FLASK_APP=api/app.py flask run --debug

# Database (Docker)
docker run --name openshield-db \
-e POSTGRES_USER=openshield \
-e POSTGRES_PASSWORD=openshield \
-e POSTGRES_DB=openshield \
-p 5432:5432 -d postgres

export DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield
alembic upgrade head

# API
FLASK_APP=api/app.py flask run --debug
```

See [Database Migrations](docs/database-migrations.md) before creating or applying
a schema change. Migration revisions are written explicitly because OpenShield
does not use ORM metadata.

---

## Code Standards
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ export AZURE_CLIENT_ID=your-client-id
export AZURE_CLIENT_SECRET=your-client-secret
export AZURE_TENANT_ID=your-tenant-id
export JWT_SECRET=your-strong-secret # used to protect write endpoints (scan trigger, AI)
export DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield

# Create or update the database schema
alembic upgrade head

# Run a scan
python -c "
Expand All @@ -175,6 +179,9 @@ print(json.dumps(result, indent=2))
FLASK_APP=api/app.py flask run
```

See [Database Migrations](docs/database-migrations.md) for schema changes and the
one-time onboarding step required for existing production databases.

**Frontend (React dashboard)**

```bash
Expand Down
42 changes: 42 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os

# DATABASE_URL is read from the environment by alembic/env.py.

[post_write_hooks]

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
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
54 changes: 54 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Alembic migration environment for OpenShield."""

import os
from logging.config import fileConfig

from alembic import context
from sqlalchemy import create_engine, pool

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

# OpenShield intentionally uses raw SQL rather than ORM metadata. Migration
# revisions therefore define every schema operation explicitly.
target_metadata = None


def get_database_url() -> str:
"""Return the configured PostgreSQL URL or fail before migration work."""
database_url = os.environ.get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL environment variable is required")
return database_url


def run_migrations_offline() -> None:
"""Generate SQL without opening a database connection."""
context.configure(
url=get_database_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 against the configured database."""
connectable = create_engine(get_database_url(), 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 changes: 28 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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:
"""Apply this migration."""
${upgrades if upgrades else "pass"}


def downgrade() -> None:
"""Revert this migration."""
${downgrades if downgrades else "pass"}
93 changes: 93 additions & 0 deletions alembic/versions/b3f1a2c4d5e6_baseline_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Create the baseline OpenShield schema.

Revision ID: b3f1a2c4d5e6
Revises:
Create Date: 2026-07-05 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# Revision identifiers, used by Alembic.
revision: str = "b3f1a2c4d5e6"
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:
"""Create the current scans and findings tables."""
op.create_table(
"scans",
sa.Column("scan_id", postgresql.UUID(), nullable=False),
sa.Column("subscription_id", sa.Text(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("total_findings", sa.Integer(), server_default=sa.text("0"), nullable=True),
sa.Column("score", sa.Integer(), server_default=sa.text("NULL"), nullable=True),
sa.Column(
"cve_enrichment_status",
sa.Text(),
server_default=sa.text("'PENDING'"),
nullable=True,
),
sa.Column("status", sa.Text(), server_default=sa.text("'pending'"), nullable=True),
sa.Column("attempt_count", sa.Integer(), server_default=sa.text("0"), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("scan_id", name="scans_pkey"),
)

op.create_table(
"findings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("scan_id", postgresql.UUID(), nullable=True),
sa.Column("rule_id", sa.Text(), nullable=False),
sa.Column("rule_name", sa.Text(), nullable=False),
sa.Column("severity", sa.Text(), nullable=False),
sa.Column("category", sa.Text(), nullable=True),
sa.Column("resource_id", sa.Text(), nullable=True),
sa.Column("resource_name", sa.Text(), nullable=True),
sa.Column("resource_type", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("remediation", sa.Text(), nullable=True),
sa.Column("playbook", sa.Text(), nullable=True),
sa.Column("frameworks", postgresql.JSONB(), nullable=True),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column(
"cve_references",
postgresql.JSONB(),
server_default=sa.text("'[]'::jsonb"),
nullable=True,
),
sa.Column("cvss_score", sa.Float(), server_default=sa.text("NULL"), nullable=True),
sa.Column(
"exploit_available",
sa.Boolean(),
server_default=sa.text("false"),
nullable=True,
),
sa.Column("detected_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["scan_id"],
["scans.scan_id"],
name="findings_scan_id_fkey",
),
sa.PrimaryKeyConstraint("id", name="findings_pkey"),
)

op.create_index("idx_findings_scan_id", "findings", ["scan_id"], unique=False)
op.create_index("idx_findings_severity", "findings", ["severity"], unique=False)
op.create_index("idx_findings_rule_id", "findings", ["rule_id"], unique=False)


def downgrade() -> None:
"""Drop the current findings and scans tables."""
op.drop_index("idx_findings_rule_id", table_name="findings")
op.drop_index("idx_findings_severity", table_name="findings")
op.drop_index("idx_findings_scan_id", table_name="findings")
op.drop_table("findings")
op.drop_table("scans")
14 changes: 0 additions & 14 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,6 @@ def create_app() -> Flask:
"Do not use this setting with real Azure scan data in production."
)

# ------------------------------------------------------------------ #
# Database Management #
# ------------------------------------------------------------------ #
# Skip migrations when DATABASE_URL is not set (e.g. during unit tests).
# Deployment startup always sets DATABASE_URL so migrations still run in
# production and staging. Tests that need a real database should set
# DATABASE_URL explicitly in their environment.
if os.environ.get("DATABASE_URL"):
with app.app_context():
db = DatabaseManager()
db.run_migrations()
else:
logger.info("DATABASE_URL not set — skipping database migrations. Set DATABASE_URL to connect to PostgreSQL.")

@app.teardown_appcontext
def close_db(error=None):
"""Ensure the database connection is closed after the request."""
Expand Down
Loading
Loading