Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,10 @@ jobs:
# ── CHECK 8: Rule regression tests (MockAzureClient, no Azure creds) ──
- name: Rule regression tests
id: rule_tests
env:
DATABASE_URL: "postgresql://ci:ci@localhost/ci_db"

run: |
echo "=== Running rule regression tests ==="
pytest tests/test_rules_*.py -v --tb=short
pytest tests/test_rules_*.py tests/test_auth.py -v --tb=short

# ── Final summary — always runs, shows per-check pass/fail ────────
- name: CI Summary
Expand Down
36 changes: 25 additions & 11 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,8 @@
)
logger = logging.getLogger(__name__)

# Paths that do not require a JWT token
# All GET requests are public — the dashboard is a public demo of seeded data.
# POST endpoints (scan trigger, AI) remain JWT-protected.
def _is_public_get(path: str) -> bool:
if path in ("/", "/health"):
return True
return path.startswith("/api/")
# Paths that are always public regardless of environment or demo mode
_ALWAYS_PUBLIC = {"/", "/health"}

_INSECURE_JWT_DEFAULT = "change-me-in-production"
_MIN_JWT_SECRET_LENGTH = 32
Expand Down Expand Up @@ -114,12 +109,29 @@ def create_app() -> Flask:
allowed_origins = allowed_origins_raw.split(",")
CORS(app, resources={r"/*": {"origins": allowed_origins}})

# ------------------------------------------------------------------ #
# Demo mode #
# ------------------------------------------------------------------ #
public_demo = os.environ.get("OPENSHIELD_PUBLIC_DEMO", "false").lower() == "true"
if public_demo:
logger.warning(
"PUBLIC DEMO MODE ENABLED (OPENSHIELD_PUBLIC_DEMO=true): "
"Unauthenticated GET requests to /api/* are permitted. "
"Do not use this setting with real Azure scan data in production."
)

# ------------------------------------------------------------------ #
# Database Management #
# ------------------------------------------------------------------ #
with app.app_context():
db = DatabaseManager()
db.run_migrations()
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):
Expand All @@ -144,7 +156,9 @@ def verify_jwt() -> None:
"""Validate the Bearer token on every non-public, non-OPTIONS request."""
if request.method == "OPTIONS":
return None
if request.method == "GET" and _is_public_get(request.path):
if request.path in _ALWAYS_PUBLIC:
return None
if public_demo and request.method == "GET":
return None

auth = request.headers.get("Authorization", "")
Expand Down
16 changes: 16 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

The OpenShield API is a Flask app registered in `api/app.py`. All `GET` requests (including `/health` and all `/api/*` GET routes) are public — no token needed. `POST` endpoints (`/api/scans/trigger`, `/api/ai/*`) require an `Authorization: Bearer <jwt>` header signed with `JWT_SECRET`.

The OpenShield API is a Flask app registered in `api/app.py`.

## Authentication

`/health` and `/` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer <jwt>` header signed with `JWT_SECRET`.

### Public demo mode

Set `OPENSHIELD_PUBLIC_DEMO=true` to allow unauthenticated GET requests to `/api/*`. This is intended for local development and public demo dashboards where the data is not sensitive. POST endpoints (scan trigger, AI) always require a valid JWT regardless of this setting.

| Environment variable | Value | GET /api/* behavior |
|---|---|---|
| `OPENSHIELD_PUBLIC_DEMO` | not set or `false` | JWT required (default) |
| `OPENSHIELD_PUBLIC_DEMO` | `true` | public, no JWT needed |

Do not enable `OPENSHIELD_PUBLIC_DEMO` in a deployment that holds real Azure scan data.
---

## GET /health
Expand Down
96 changes: 96 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for JWT authentication middleware — production and demo modes."""

import os
import secrets
import time

import jwt
import pytest


_SECRET = secrets.token_urlsafe(32)


def _make_token() -> str:
payload = {
"sub": "test-user",
"role": "admin",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
}
return jwt.encode(payload, _SECRET, algorithm="HS256")


@pytest.fixture
def prod_client():
"""Flask test client with default (JWT-required) auth mode."""
os.environ.pop("OPENSHIELD_PUBLIC_DEMO", None)
from api.app import create_app
app = create_app()
app.config["TESTING"] = True
app.config["JWT_SECRET"] = _SECRET
return app.test_client()


@pytest.fixture
def demo_client():
"""Flask test client with OPENSHIELD_PUBLIC_DEMO=true."""
os.environ["OPENSHIELD_PUBLIC_DEMO"] = "true"
try:
from api.app import create_app
app = create_app()
app.config["TESTING"] = True
app.config["JWT_SECRET"] = _SECRET
yield app.test_client()
finally:
os.environ.pop("OPENSHIELD_PUBLIC_DEMO", None)


# ── /health is always public ─────────────────────────────────────────────────

def test_health_public_in_default_mode(prod_client):
assert prod_client.get("/health").status_code == 200


def test_health_public_in_demo_mode(demo_client):
assert demo_client.get("/health").status_code == 200


# ── Default mode: GET /api/* requires JWT ────────────────────────────────────

def test_api_get_requires_jwt_no_header(prod_client):
assert prod_client.get("/api/findings").status_code == 401


def test_api_get_requires_jwt_bad_token(prod_client):
resp = prod_client.get("/api/findings", headers={"Authorization": "Bearer not-a-real-token"})
assert resp.status_code == 401


def test_api_get_passes_auth_with_valid_jwt(prod_client):
headers = {"Authorization": f"Bearer {_make_token()}"}
resp = prod_client.get("/api/findings", headers=headers)
# Auth passed — downstream may be 200 or 500 (no DB in CI), but must not be 401
assert resp.status_code != 401


def test_api_post_requires_jwt_in_default_mode(prod_client):
resp = prod_client.post("/api/scans/trigger", json={})
assert resp.status_code == 401


# ── Demo mode: GET /api/* is public, POST still requires JWT ─────────────────

def test_demo_get_allowed_without_jwt(demo_client):
resp = demo_client.get("/api/findings")
assert resp.status_code != 401


def test_demo_post_still_requires_jwt(demo_client):
resp = demo_client.post("/api/scans/trigger", json={})
assert resp.status_code == 401


def test_demo_score_allowed_without_jwt(demo_client):
resp = demo_client.get("/api/score")
assert resp.status_code != 401
Loading