Skip to content
Merged

Dev #94

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
93dfdb5
chore: add CLAUDE.md to .gitignore
pparage Apr 15, 2026
e573d5f
fix(security): read SECRET_KEY and SECURITY_PASSWORD_SALT from env vars
pparage Apr 15, 2026
04f99aa
fix(security): add ownership check on schema edit and delete routes
pparage Apr 15, 2026
ddf1980
fix(config): use postgresql:// scheme in production sample (SQLAlchem…
pparage Apr 15, 2026
6dfbc90
fix(security): harden schema permission decorator and add org_id serv…
pparage Apr 15, 2026
232a087
feat(schema): two-step delete with confirmation page (closes #4)
pparage Apr 15, 2026
b4c375b
fix(schema): harden delete flow — CSRF validation, 404 guards, owner-…
pparage Apr 15, 2026
23f9316
feat(schema): add schema fork with provenance tracking (closes #3)
pparage Apr 16, 2026
a6f8755
chore(deps): update Python and JS dependencies to fix CVEs
pparage Apr 16, 2026
15c4726
chore(release): bump version to 0.18.0 and update CHANGELOG
pparage Apr 16, 2026
099fd3e
fix: address post-review findings before merge
pparage Apr 16, 2026
b2b476f
fix: replace deprecated datetime.utcnow() and API debug prints
pparage Apr 16, 2026
4d24194
chore(release): update CHANGELOG for v0.18.0 final items
pparage Apr 16, 2026
4b19f85
fix: replace stray print(e) with logger.error in objectbp.py
pparage Apr 16, 2026
ef38aca
fix(ui): Bootstrap 4→5 migration — data-bs-* attributes and btn-close
pparage Apr 16, 2026
7c3299b
feat(ui): upgrade to CodeMirror 6 with esbuild bundle
pparage Apr 16, 2026
3c28301
fix(ui): replace popper.js v1 with @popperjs/core v2 for Bootstrap 5
pparage Apr 16, 2026
d87ad7b
fix(ui): fix navbar dropdown clipping for Bootstrap 5
pparage Apr 16, 2026
2a3a006
fix(ux): show helpful messages when user has no organizations
pparage Apr 16, 2026
008612f
fix(ui): replace bootstrap-select with native Bootstrap 5 form-select
pparage Apr 16, 2026
5146bbe
fix(ui): clean up edit_collection.html for Bootstrap 5
pparage Apr 16, 2026
d68085f
fix(ui): complete Bootstrap 4→5 migration across all templates
pparage Apr 16, 2026
2febfca
ui: Bootstrap 5 polish, accessibility fixes, and empty-state messages
pparage Apr 16, 2026
c38fd55
refactor: address PR #94 review feedback
cedricbonhomme Apr 17, 2026
4360a76
fix(ui): wrap home page recent-contribution lists in list-group
cedricbonhomme Apr 17, 2026
58219b1
ci: fix flake8 failures and update Python matrix to 3.11+
cedricbonhomme Apr 17, 2026
d9a9c8c
ci: keep only Python 3.11 in matrix pending flake8 bump
cedricbonhomme Apr 17, 2026
e1d98c0
ci: set minimal config defaults for the testing environment
cedricbonhomme Apr 17, 2026
aadf5d4
ci: seed SECRET_KEY and SECURITY_PASSWORD_SALT in the test config
cedricbonhomme Apr 17, 2026
d1f376e
ci: use direct assignment for test config (setdefault was a no-op)
cedricbonhomme Apr 17, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/pythonapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:

strategy:
matrix:
python-version: [3.9, 3.10.9, 3.11.0]
python-version: ["3.11"]

steps:
- uses: actions/checkout@v1
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ dist/
*.egg-info/
.python-version

# Claude Code
CLAUDE.md

# Package manager artifacts (not used by this project)
uv.lock

# Local instance configs (not for version control)
instance/test.py

# Project related
var/log/
docs/_build/
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ Changelog
=========


v0.18.0 (2026-04-16)
---------------------

Security
~~~~~~~~
- Enforce SECRET_KEY and SECURITY_PASSWORD_SALT from environment variables;
raise RuntimeError on startup if a known-weak value is detected (closes #79).
- Add ownership authorization to schema edit and delete routes (IDOR fix);
users may only modify schemas belonging to their organization.
- Validate org_id server-side on schema creation to prevent privilege escalation.
- Upgrade Flask (3.1.3), Werkzeug (3.1.8), flask-cors (6.0.2), requests (2.33.1),
certifi, and idna to fix multiple published CVEs.

New
~~~
- Schema delete is now a two-step flow: a GET confirmation page showing the
cascade-delete count, followed by a POST to perform the deletion (closes #4).
- Schema fork: any authenticated user can copy a public schema into their own
organization via a modal dialog; forked schemas display a provenance badge
linking back to the source (closes #3).
- Proper HTTP 403 error page replacing the previous redirect-to-login behavior.

Changes
~~~~~~~
- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) across
all models and views (Python 3.12 compatibility).
- Replace print() debug calls in api/v2 with proper structured logging.
- Use postgresql:// scheme in all config files (SQLAlchemy 2.x compatibility).
- Update JS dependencies: bootstrap 5.3.8, chart.js 4.5.1, codemirror 6.0.2,
datatables.net-bs4 2.3.7, @json-editor/json-editor 2.16.0, papaparse 5.5.3,
@fortawesome/fontawesome-free 6.7.2.


v0.17.1 (2021-10-28)
--------------------

Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ services:
- MOSP_CONFIG=docker.py
- HOST=0.0.0.0
- PORT=5000
# Required: set SECRET_KEY and SECURITY_PASSWORD_SALT via a .env file
# or by passing them as environment variables before running docker-compose.
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
# Example .env file:
# SECRET_KEY=<output of above command>
# SECURITY_PASSWORD_SALT=<output of above command again>
command: "./entrypoint.sh"
volumes:
- .:/mosp:rw
Expand Down
2 changes: 1 addition & 1 deletion instance/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"port": 5432,
}
DATABASE_NAME = "mosp"
SQLALCHEMY_DATABASE_URI = "postgres://{user}:{password}@{host}:{port}/{name}".format(
SQLALCHEMY_DATABASE_URI = "postgresql://{user}:{password}@{host}:{port}/{name}".format(
name=DATABASE_NAME, **DB_CONFIG_DICT
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
Expand Down
4 changes: 2 additions & 2 deletions instance/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
)
SQLALCHEMY_TRACK_MODIFICATIONS = os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS", "0") == "1"

SECRET_KEY = "LCx3BchmHRxFzkEv4BqQJyeXRLXenf"
SECURITY_PASSWORD_SALT = "L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD"
SECRET_KEY = os.environ.get("SECRET_KEY", "")
SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT", "")

LOG_PATH = "./var/log/mosp.log"
LOG_LEVEL = "info"
Expand Down
4 changes: 2 additions & 2 deletions instance/heroku.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "").replace("://", "ql://", 1)
SQLALCHEMY_TRACK_MODIFICATIONS = False

SECRET_KEY = "SECRET KEY"
SECURITY_PASSWORD_SALT = "SECURITY PASSWORD SALT"
SECRET_KEY = os.environ["SECRET_KEY"]
SECURITY_PASSWORD_SALT = os.environ["SECURITY_PASSWORD_SALT"]

SELF_REGISTRATION = True

Expand Down
8 changes: 5 additions & 3 deletions instance/production.py.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

HOST = "127.0.0.1"
PORT = 5000
TESTING = False
Expand All @@ -10,13 +12,13 @@ DB_CONFIG_DICT = {
"port": 5432,
}
DATABASE_NAME = "mosp"
SQLALCHEMY_DATABASE_URI = "postgres://{user}:{password}@{host}:{port}/{name}".format(
SQLALCHEMY_DATABASE_URI = "postgresql://{user}:{password}@{host}:{port}/{name}".format(
name=DATABASE_NAME, **DB_CONFIG_DICT
)
SQLALCHEMY_TRACK_MODIFICATIONS = False

SECRET_KEY = "LCx3BchmHRxFzkEv4BqQJyeXRLXenf"
SECURITY_PASSWORD_SALT = "L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD"
SECRET_KEY = os.environ.get("SECRET_KEY", "")
SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT", "")

SELF_REGISTRATION = True

Expand Down
35 changes: 35 additions & 0 deletions migrations/versions/c3f2a1b4e5d6_add_forked_from_id_to_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add forked_from_id to schema

Revision ID: c3f2a1b4e5d6
Revises: 44015f761a68
Create Date: 2026-04-15 15:30:00.000000

"""
import sqlalchemy as sa
from alembic import op


# revision identifiers, used by Alembic.
revision = "c3f2a1b4e5d6"
down_revision = "44015f761a68"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"schema",
sa.Column("forked_from_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_schema_forked_from_id",
"schema",
"schema",
["forked_from_id"],
["id"],
Comment on lines +24 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow deleting schemas that have been forked

The new self-referential foreign key on schema.forked_from_id is created without an ondelete action, so PostgreSQL keeps the default NO ACTION behavior. After this migration, deleting an original schema that has at least one fork will raise an integrity error and make the new /schema/delete/<id> flow fail with a 500 at commit time. This blocks a core operation for any schema that has been forked.

Useful? React with 👍 / 👎.

)


def downgrade():
op.drop_constraint("fk_schema_forked_from_id", "schema", type_="foreignkey")
op.drop_column("schema", "forked_from_id")
5 changes: 4 additions & 1 deletion mosp/api/v2/collection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#! /usr/bin/env python
import logging

from flask_restx import fields
from flask_restx import Namespace
from flask_restx import reqparse
Expand All @@ -9,6 +11,7 @@
from mosp.api.v2.types import ResultType
from mosp.models import Collection

logger = logging.getLogger(__name__)

collection_ns = Namespace("collection", description="collection related operations")

Expand Down Expand Up @@ -76,7 +79,7 @@ def get(self):
results = query.offset(offset * limit)
count = total
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

result["data"] = results
result["metadata"]["count"] = count
Expand Down
2 changes: 1 addition & 1 deletion mosp/api/v2/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def post(self):
sqlalchemy.exc.InvalidRequestError,
) as e:
logger.error("Error when creating object {}".format(object["id"]))
print(e)
logger.error(str(e), exc_info=True)
# errors.append(object["id"])
db.session.rollback()

Expand Down
5 changes: 4 additions & 1 deletion mosp/api/v2/organization.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#! /usr/bin/env python
import logging

from flask_restx import fields
from flask_restx import inputs
from flask_restx import Namespace
Expand All @@ -10,6 +12,7 @@
from mosp.api.v2.types import ResultType
from mosp.models import Organization

logger = logging.getLogger(__name__)

organization_ns = Namespace(
"organization", description="organization related operations"
Expand Down Expand Up @@ -79,7 +82,7 @@ def get(self):
results = query.offset(offset * limit)
count = total
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

result["data"] = results
result["metadata"]["count"] = count
Expand Down
5 changes: 4 additions & 1 deletion mosp/api/v2/schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#! /usr/bin/env python
import logging

from flask_restx import fields
from flask_restx import Namespace
from flask_restx import reqparse
Expand All @@ -9,6 +11,7 @@
from mosp.api.v2.types import ResultType
from mosp.models import Schema

logger = logging.getLogger(__name__)

schema_ns = Namespace("schema", description="schema related operations")

Expand Down Expand Up @@ -86,7 +89,7 @@ def get(self):
results = query.offset(offset * limit)
count = total
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

result["data"] = results
result["metadata"]["count"] = count
Expand Down
6 changes: 4 additions & 2 deletions mosp/api/v2/user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#! /usr/bin/env python
import logging
import secrets

import sqlalchemy
Expand All @@ -22,6 +23,7 @@
from mosp.models import User
from mosp.notifications import notifications

logger = logging.getLogger(__name__)

user_ns = Namespace("user", description="user related operations")

Expand Down Expand Up @@ -115,7 +117,7 @@ def get(self):
results = query.offset(offset * limit)
count = total
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

result["data"] = results
result["metadata"]["count"] = count
Expand Down Expand Up @@ -169,7 +171,7 @@ def post(self):
try:
notifications.confirm_account(new_user)
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

# marshalling will skip none values and we do not want to return the API key
new_user.apikey = None
Expand Down
5 changes: 4 additions & 1 deletion mosp/api/v2/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#! /usr/bin/env python
import logging

from flask_restx import fields
from flask_restx import Namespace
from flask_restx import reqparse
Expand All @@ -9,6 +11,7 @@
from mosp.api.v2.types import ResultType
from mosp.models import Version

logger = logging.getLogger(__name__)

version_ns = Namespace("version", description="version related operations")

Expand Down Expand Up @@ -72,7 +75,7 @@ def get(self):
results = query.offset(offset * limit)
count = total
except Exception as e:
print(e)
logger.error(str(e), exc_info=True)

result["data"] = results
result["metadata"]["count"] = count
Expand Down
35 changes: 35 additions & 0 deletions mosp/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ def set_logging(
application.config[
"SQLALCHEMY_DATABASE_URI"
] = "postgresql://mosp:password@localhost:5432/mosp"
# Minimal defaults so the app (incl. API v2 setup) imports cleanly
# without requiring a real instance config file.
# Direct assignment (not setdefault) because Flask's default_config
# seeds SECRET_KEY/TESTING with None/False, which would make setdefault
# a silent no-op.
application.config["TESTING"] = True
application.config["WTF_CSRF_ENABLED"] = False
application.config["SECRET_KEY"] = "testing-only-not-a-real-secret"
application.config["SECURITY_PASSWORD_SALT"] = "testing-only-not-a-real-salt"
application.config["ADMIN_EMAIL"] = "admin@test.local"
application.config["ADMIN_URL"] = "http://test.local"
application.config["INSTANCE_URL"] = "http://test.local"
elif ON_HEROKU:
# Deployment on Heroku
application.config.from_pyfile("heroku.py", silent=False)
Expand All @@ -75,6 +87,28 @@ def set_logging(
except Exception:
application.config.from_pyfile("development.py", silent=False)

_KNOWN_WEAK_KEYS = {
"",
"dev",
"SECRET KEY",
"SECURITY PASSWORD SALT",
"LCx3BchmHRxFzkEv4BqQJyeXRLXenf",
"L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD", # old SECURITY_PASSWORD_SALT default
}

if not application.config.get("TESTING") and os.environ.get("testing") != "actions":
if application.config.get("SECRET_KEY", "") in _KNOWN_WEAK_KEYS:
raise RuntimeError(
"SECRET_KEY is not set or uses a known insecure default. "
"Set the SECRET_KEY environment variable to a strong random value "
'(e.g. python -c "import secrets; print(secrets.token_hex(32))").'
)
if application.config.get("SECURITY_PASSWORD_SALT", "") in _KNOWN_WEAK_KEYS:
raise RuntimeError(
"SECURITY_PASSWORD_SALT is not set or uses a known insecure default. "
"Set the SECURITY_PASSWORD_SALT environment variable."
)

# Database and migration
db = SQLAlchemy(application)
migrate = Migrate(application, db)
Expand All @@ -90,6 +124,7 @@ def set_logging(
},
)


# i18n and l10n support
def get_locale():
# if a user is logged in, use the locale from the user settings
Expand Down
6 changes: 6 additions & 0 deletions mosp/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,9 @@ class CollectionForm(FlaskForm):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)


class SimpleForm(FlaskForm):
"""A minimal form used solely for CSRF protection (e.g. confirmation pages)."""

pass
12 changes: 12 additions & 0 deletions mosp/models/_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from datetime import datetime
from datetime import timezone


def utcnow_naive() -> datetime:
"""Current UTC time as a naive datetime.

The DateTime columns in this project are declared without timezone=True,
so tz-aware values would fail to insert. Strip tzinfo after computing in
UTC; this is the non-deprecated replacement for datetime.utcnow().
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
Loading
Loading