Repository: freedomofpress/securedrop

Issue:
In dev environment, multiple active InstanceConfig records can be created, breaking things
## Description

If the database is created with `create_all` instead of migrations, there won't be a default `InstanceConfig` record. On first request the [source](https://github.com/freedomofpress/securedrop/blob/2971b91b31ba9abf0d6372f4f4db9d796f83e1c5/securedrop/source_app/__init__.py#L136) or [journalist](https://github.com/freedomofpress/securedrop/blob/2971b91b31ba9abf0d6372f4f4db9d796f83e1c5/securedrop/journalist_app/__init__.py#L127) apps will try to [create one](https://github.com/freedomofpress/securedrop/blob/2971b91b31ba9abf0d6372f4f4db9d796f83e1c5/securedrop/models.py#L862), and if there are simultaneous requests, it's possible that more than one active (with `valid_until` null) row can be created, causing problems on subsequent requests.

Changing the dev environment to create the database the same way it's done in staging and production would ensure the presence of an `InstanceConfig` before the apps are started, but we should also try to prevent multiple active configs. A [partial index](https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#partial-indexes) should do that.

## Steps to Reproduce

This should only happen in the dev environment, and it's tricky to reproduce _in vivo_; you have to submit simultaneous requests right after the dev servers are started. We saw this in the demo service when multiple scripts were testing the site.

It's easier to demonstrate the problem with the `instance_config` schema alone: in one database session, add an `InstanceConfig` instance but don't commit, then in another session, call `InstanceConfig.get_current()` to create a record with the same default values, then commit the original session. This will work and you'll wind up with two active configurations in the table.


Discussion:

Resolving patch:
diff --git a/securedrop/alembic/versions/1ddb81fb88c2_unique_index_for_instanceconfig_valid_.py b/securedrop/alembic/versions/1ddb81fb88c2_unique_index_for_instanceconfig_valid_.py
new file mode 100644
--- /dev/null
+++ b/securedrop/alembic/versions/1ddb81fb88c2_unique_index_for_instanceconfig_valid_.py
@@ -0,0 +1,32 @@
+"""unique_index_for_instanceconfig_valid_until
+
+Revision ID: 1ddb81fb88c2
+Revises: 92fba0be98e9
+Create Date: 2021-06-04 17:28:25.725563
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1ddb81fb88c2'
+down_revision = '92fba0be98e9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('instance_config', schema=None) as batch_op:
+        batch_op.create_index('ix_one_active_instance_config', [sa.text('valid_until IS NULL')], unique=True, sqlite_where=sa.text('valid_until IS NULL'))
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('instance_config', schema=None) as batch_op:
+        batch_op.drop_index('ix_one_active_instance_config')
+
+    # ### end Alembic commands ###
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -18,7 +18,8 @@
 from passlib.hash import argon2
 from sqlalchemy import ForeignKey
 from sqlalchemy.orm import relationship, backref, Query, RelationshipProperty
-from sqlalchemy import Column, Integer, String, Boolean, DateTime, LargeBinary
+from sqlalchemy import Column, Index, Integer, String, Boolean, DateTime, LargeBinary
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
 
 from db import db
@@ -833,7 +834,6 @@ class InstanceConfig(db.Model):
     __tablename__ = 'instance_config'
     version = Column(Integer, primary_key=True)
     valid_until = Column(DateTime, default=None, unique=True)
-
     allow_document_uploads = Column(Boolean, default=True)
     organization_name = Column(String(255), nullable=True, default="SecureDrop")
 
@@ -869,10 +869,13 @@ def get_current(cls) -> "InstanceConfig":
         try:
             return cls.query.filter(cls.valid_until == None).one()  # lgtm [py/test-equals-none]  # noqa: E711, E501
         except NoResultFound:
-            current = cls()
-            db.session.add(current)
-            db.session.commit()
-            return current
+            try:
+                current = cls()
+                db.session.add(current)
+                db.session.commit()
+                return current
+            except IntegrityError:
+                return cls.query.filter(cls.valid_until == None).one()  # lgtm [py/test-equals-none]  # noqa: E711, E501
 
     @classmethod
     def check_name_acceptable(cls, name: str) -> None:
@@ -914,3 +917,11 @@ def set_allow_document_uploads(cls, value: bool) -> None:
         db.session.add(new)
 
         db.session.commit()
+
+
+one_active_instance_config_index = Index(
+    'ix_one_active_instance_config',
+    InstanceConfig.valid_until.is_(None),
+    unique=True,
+    sqlite_where=(InstanceConfig.valid_until.is_(None))
+)


Test patch:
diff --git a/securedrop/tests/migrations/migration_1ddb81fb88c2.py b/securedrop/tests/migrations/migration_1ddb81fb88c2.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/migrations/migration_1ddb81fb88c2.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+
+from sqlalchemy import text
+
+from db import db
+from journalist_app import create_app
+
+
+index_definition = (
+    "index",
+    "ix_one_active_instance_config",
+    "instance_config",
+    (
+        "CREATE UNIQUE INDEX ix_one_active_instance_config "
+        "ON instance_config (valid_until IS NULL) WHERE valid_until IS NULL"
+    ),
+)
+
+
+def get_schema(app):
+    with app.app_context():
+        result = list(
+            db.engine.execute(text("SELECT type, name, tbl_name, sql FROM sqlite_master"))
+        )
+
+    return ((x[0], x[1], x[2], x[3]) for x in result)
+
+
+class UpgradeTester:
+    """
+    Ensure that the new index is created.
+    """
+
+    def __init__(self, config):
+        self.app = create_app(config)
+
+    def load_data(self):
+        pass
+
+    def check_upgrade(self):
+        schema = get_schema(self.app)
+        print(schema)
+        assert index_definition in schema
+
+
+class DowngradeTester:
+    """
+    Ensure that the new index is removed.
+    """
+
+    def __init__(self, config):
+        self.app = create_app(config)
+
+    def load_data(self):
+        pass
+
+    def check_downgrade(self):
+        assert index_definition not in get_schema(self.app)
diff --git a/securedrop/tests/test_db.py b/securedrop/tests/test_db.py
--- a/securedrop/tests/test_db.py
+++ b/securedrop/tests/test_db.py
@@ -3,9 +3,19 @@
 
 from mock import MagicMock
 
+from sqlalchemy import create_engine
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import sessionmaker
+
 from .utils import db_helper
-from models import (Journalist, Submission, Reply, get_one_or_else,
-                    LoginThrottledException)
+from models import (
+    InstanceConfig,
+    Journalist,
+    Submission,
+    Reply,
+    LoginThrottledException,
+    get_one_or_else
+)
 
 
 def test_get_one_or_else_returns_one(journalist_app, test_journo):
@@ -90,3 +100,32 @@ def test_journalist_string_representation(journalist_app, test_journo):
 def test_source_string_representation(journalist_app, test_source):
     with journalist_app.app_context():
         test_source['source'].__repr__()
+
+
+def test_only_one_active_instance_config_can_exist(config, source_app):
+    """
+    Checks that attempts to add multiple active InstanceConfig records fail.
+
+    InstanceConfig is supposed to be invalidated by setting
+    valid_until to the time the config was no longer in effect. Until
+    we added the partial index preventing multiple rows with a null
+    valid_until, it was possible for the system to create multiple
+    active records, which would cause MultipleResultsFound exceptions
+    in InstanceConfig.get_current.
+    """
+    # create a separate session
+    engine = create_engine(source_app.config['SQLALCHEMY_DATABASE_URI'])
+    session = sessionmaker(bind=engine)()
+
+    # in the separate session, create an InstanceConfig with default
+    # values, but don't commit it
+    conflicting_config = InstanceConfig()
+    session.add(conflicting_config)
+
+    with source_app.app_context():
+        # get_current will create another InstanceConfig with default values
+        InstanceConfig.get_current()
+
+    # now the commit of the first instance should fail
+    with pytest.raises(IntegrityError):
+        session.commit()