Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add journalist interface API #3619

Merged
merged 71 commits into from
Jul 24, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
4e90665
Journalist API: Add initial Blueprint with root endpoint
redshiftzero Jun 18, 2018
8068543
Remove deprecated autoversion
redshiftzero Jun 18, 2018
b10dbe3
Journalist API: Rework login by default logic
redshiftzero Jun 18, 2018
55241d9
Journalist interface: Add 403 error handler
redshiftzero Jun 18, 2018
0a7fcbb
Journalist API: Add endpoint to do API token auth
redshiftzero Jun 18, 2018
3402155
Journalist API: Use decorator for protected API endpoints
redshiftzero Jun 18, 2018
efad309
Journalist API: Add pytest fixture for journalist API token
redshiftzero Jun 18, 2018
98d43d2
Journalist API /sources/: placeholders and get_all_sources()
redshiftzero Jun 18, 2018
efe2726
Test source pytest fixture: Add submissions for convenience
redshiftzero Jun 18, 2018
dac442c
Journalist API: Add security test cases for HTTP GETs
redshiftzero Jun 18, 2018
79e5b60
Journalist API: Security test cases for HTTP DELETEs
redshiftzero Jun 18, 2018
f6a42c5
Journalist API: Add security test cases for HTTP POSTs
redshiftzero Jun 18, 2018
35d032e
Journalist interface: Add 404 error handler
redshiftzero Jun 18, 2018
d32da8d
Journalist API: Add single source endpoint (/sources/<int:id>)
redshiftzero Jun 23, 2018
73e0aa7
Journalist API: Add star endpoint: /sources/<int:source_id>/star/
redshiftzero Jun 23, 2018
122664e
Journalist API: Remove star endpoint: /sources/<int:source_id>/star/
redshiftzero Jun 23, 2018
8b425db
Journalist API: Catch itsdangerous.BadData only
redshiftzero Jun 23, 2018
8ae05b1
Journalist interface: Add 405 error handler
redshiftzero Jun 23, 2018
3504688
Journalist API: Get all submissions [/submissions/]
redshiftzero Jun 23, 2018
1324687
Journalist API: GET a source's submissions [/source/:id/submissions]
redshiftzero Jun 23, 2018
b4e9bb2
Journalist API: GET a single submission [/sources/:id/submissions/:id]
redshiftzero Jun 23, 2018
d2c1ce3
Journalist API: DELETE individual source submission
redshiftzero Jun 23, 2018
1fef4da
Journalist API: DELETE source collection
redshiftzero Jun 23, 2018
15baf24
Journalist API: Add endpoint to download PGP submissions
redshiftzero Jun 23, 2018
4fc88f7
Journalist API: Add /user endpoint for user information
redshiftzero Jun 23, 2018
244ff82
Journalist API: Rework token_required, add unit tests
redshiftzero Jun 24, 2018
4f93e8f
Journalist API: Add pre-encrypted source reply endpoint
redshiftzero Jun 24, 2018
6d069a8
Journalist API: Reject plaintext replies server-side
redshiftzero Jun 24, 2018
7adb254
Journalist API: API should provide armored pubkey of source
redshiftzero Jun 24, 2018
4596452
Journalist interface: Add 400 error handler
redshiftzero Jun 24, 2018
d6765c8
Journalist API: cleanup and use same slug as journalist interface
redshiftzero Jun 24, 2018
024ff35
Journalist API: Refactor error handling
redshiftzero Jun 29, 2018
1d43208
Journalist API: Expiration date should be consistent
redshiftzero Jun 29, 2018
183cc3b
Journalist API: Developer documentation
redshiftzero Jun 25, 2018
eea5e9e
Journalist API: Update last_access metadata with token auth
redshiftzero Jun 29, 2018
b2729f8
AppArmor: Update apache2 profile for journalist API
redshiftzero Jun 29, 2018
2621cf4
Journalist API: Remove trailing slashes from all URLs
redshiftzero Jul 2, 2018
3be0707
Journalist API: Use generic 400 for rejected plaintext reply
redshiftzero Jul 2, 2018
586769a
Journalist API: [], {} are valid JSON
redshiftzero Jul 2, 2018
2002347
Journalist API: Return ISO8601 format expiraton from /token
redshiftzero Jul 3, 2018
978f584
Journalist API: Add public_key_type field for futureproofing
redshiftzero Jul 3, 2018
8cf6024
Use generic error handlers for Werkzeug's default_exceptions
redshiftzero Jul 3, 2018
3ab39c7
Journalist API: Rename verify_api_token for clarity
redshiftzero Jul 3, 2018
3908748
Journalist API: Rename password->passphrase for clarity
redshiftzero Jul 3, 2018
545c9d1
Journalist API: Modify URI for delete collection endpoint
redshiftzero Jul 5, 2018
02432ed
Journalist API: Implement flag for reply
redshiftzero Jul 5, 2018
b490778
Journalist API: Add helper auth function
redshiftzero Jul 5, 2018
172e0fb
Journalist API: Make get_or_404 clearer
redshiftzero Jul 5, 2018
b6ad7cd
Journalist API: Refactor exception handling on get_token
redshiftzero Jul 6, 2018
e11cf0f
Journalist API: Settle on method for detected plaintext
redshiftzero Jul 6, 2018
f34f08c
Resolve mypy issues on werkzeug.exceptions.default_exceptions
redshiftzero Jul 6, 2018
0d86a30
Journalist API: Add security test case for none alg
redshiftzero Jul 6, 2018
0b9f6c6
Journalist API: Add security test case for deleted user
redshiftzero Jul 6, 2018
785d4d2
Journalist API: Update sources fields
redshiftzero Jul 7, 2018
8685884
Journalist API: Add test case for trailing slash on end of URL
redshiftzero Jul 7, 2018
52d6c5a
Journalist API: Increase API token expiration for UX reasons
redshiftzero Jul 7, 2018
c324abe
Journalist API: Return ISO 8601 compliant dates from API
redshiftzero Jul 9, 2018
daecdd7
Journalist API: Create Source.uuid column
redshiftzero Jul 9, 2018
b6d239e
Alembic: Support batch operations for database migrations
redshiftzero Jul 9, 2018
8f385a9
Bugfix: Upgrade test should be on old database, then load data
redshiftzero Jul 10, 2018
664be6e
API docs: Update for UUID change
redshiftzero Jul 10, 2018
ebe1553
Database migration: Add migration and tests for source UUID column
redshiftzero Jul 9, 2018
b5447d7
updated schema check to handle whitespace differences
heartsucker Jun 8, 2018
25cec03
Alembic tests: Be more lenient with schema equality check
redshiftzero Jul 11, 2018
900f3b3
Journalist API: Also use UUID for submissions
redshiftzero Jul 12, 2018
7e7a447
Journalist API: Add ETag header containing SHA256 hash of data
redshiftzero Jul 13, 2018
5ab60f7
Remove unnecessary _insecure_api_views
redshiftzero Jul 13, 2018
dc21b3c
Journalist API tests: Ensure root endpoint exposes all endpoints
redshiftzero Jul 17, 2018
1b852b0
Indicate source.public_key setter and deleter are not implemented
redshiftzero Jul 18, 2018
57dd181
Journalist API tests: Add submissions fixture
redshiftzero Jul 18, 2018
e42c7f1
Journalist API: Only show pending=False sources
redshiftzero Jul 18, 2018
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
472 changes: 472 additions & 0 deletions docs/development/journalist_api.rst

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ anonymous sources.
development/making_pr
development/admin_development
development/updategui_development
development/journalist_api
development/virtual_environments
development/virtualizing_tails
development/contributor_guidelines
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@
/var/www/securedrop/journalist_app/account.pyc rw,
/var/www/securedrop/journalist_app/admin.py r,
/var/www/securedrop/journalist_app/admin.pyc rw,
/var/www/securedrop/journalist_app/api.py r,
/var/www/securedrop/journalist_app/api.pyc rw,
/var/www/securedrop/journalist_app/col.py r,
/var/www/securedrop/journalist_app/col.pyc rw,
/var/www/securedrop/journalist_app/decorators.py r,
Expand Down
3 changes: 2 additions & 1 deletion securedrop/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ def run_migrations_online():
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
target_metadata=target_metadata,
render_as_batch=True
)

with context.begin_transaction():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Create source UUID column

Revision ID: 3d91d6948753
Revises: faac8092c123
Create Date: 2018-07-09 22:39:05.088008

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import quoted_name
import subprocess
import uuid

# revision identifiers, used by Alembic.
revision = '3d91d6948753'
down_revision = 'faac8092c123'
branch_labels = None
depends_on = None


def upgrade():
# Schema migration
op.rename_table('sources', 'sources_tmp')

# Add UUID column.
op.add_column('sources_tmp', sa.Column('uuid', sa.String(length=36)))

# Add UUIDs to sources_tmp table.
conn = op.get_bind()
sources = conn.execute(sa.text("SELECT * FROM sources_tmp")).fetchall()

for source in sources:
id = source.id
source_uuid = str(uuid.uuid4())
conn.execute(
sa.text("UPDATE sources_tmp SET uuid=('{}') WHERE id={}".format(
source_uuid, id)))

# Now create new table with unique constraint applied.
op.create_table(quoted_name('sources', quote=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=False),
sa.Column('filesystem_id', sa.String(length=96), nullable=True),
sa.Column('journalist_designation', sa.String(length=255),
nullable=False),
sa.Column('flagged', sa.Boolean(), nullable=True),
sa.Column('last_updated', sa.DateTime(), nullable=True),
sa.Column('pending', sa.Boolean(), nullable=True),
sa.Column('interaction_count', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('uuid'),
sa.UniqueConstraint('filesystem_id')
)

# Data Migration: move all sources into the new table.
conn.execute('''
INSERT INTO sources
SELECT id, uuid, filesystem_id, journalist_designation, flagged,
last_updated, pending, interaction_count
FROM sources_tmp
''')

# Now delete the old table.
op.drop_table('sources_tmp')


def downgrade():
with op.batch_alter_table('sources', schema=None) as batch_op:
batch_op.drop_column('uuid')
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""create submission uuid column

Revision ID: fccf57ceef02
Revises: 3d91d6948753
Create Date: 2018-07-12 00:06:20.891213

"""
from alembic import op
import sqlalchemy as sa

import uuid

# revision identifiers, used by Alembic.
revision = 'fccf57ceef02'
down_revision = '3d91d6948753'
branch_labels = None
depends_on = None


def upgrade():
# Schema migration
op.rename_table('submissions', 'submissions_tmp')

# Add UUID column.
op.add_column('submissions_tmp', sa.Column('uuid', sa.String(length=36)))

# Add UUIDs to submissions_tmp table.
conn = op.get_bind()
submissions = conn.execute(
sa.text("SELECT * FROM submissions_tmp")).fetchall()

for submission in submissions:
id = submission.id
submission_uuid = str(uuid.uuid4())
conn.execute(
sa.text("""UPDATE submissions_tmp
SET uuid=('{}')
WHERE id={}""".format(submission_uuid, id)))

# Now create new table with unique constraint applied.
op.create_table('submissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('downloaded', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('uuid')
)

# Data Migration: move all submissions into the new table.
conn.execute('''
INSERT INTO submissions
SELECT id, uuid, source_id, filename, size, downloaded
FROM submissions_tmp
''')

# Now delete the old table.
op.drop_table('submissions_tmp')


def downgrade():
with op.batch_alter_table('submissions', schema=None) as batch_op:
batch_op.drop_column('uuid')
4 changes: 4 additions & 0 deletions securedrop/crypto_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ def getkey(self, name):
return key['fingerprint']
return None

def export_pubkey(self, name):
fingerprint = self.getkey(name)
return self.gpg.export_keys(fingerprint)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍


def encrypt(self, plaintext, fingerprints, output=None):
# Verify the output path
if output:
Expand Down
41 changes: 25 additions & 16 deletions securedrop/journalist_app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# -*- coding: utf-8 -*-

from datetime import datetime, timedelta
from flask import Flask, session, redirect, url_for, flash, g, request
from flask import (Flask, session, redirect, url_for, flash, g, request,
render_template)
from flask_assets import Environment
from flask_babel import gettext
from flask_wtf.csrf import CSRFProtect, CSRFError
from os import path
from werkzeug.exceptions import default_exceptions # type: ignore

import i18n
import template_filters
import version

from crypto_util import CryptoUtil
from db import db
from journalist_app import account, admin, main, col
from journalist_app import account, admin, api, main, col
from journalist_app.utils import get_source, logged_in
from models import Journalist
from store import Storage
Expand All @@ -39,7 +41,7 @@ def create_app(config):
app.config.from_object(config.JournalistInterfaceFlaskConfig)
app.sdconfig = config

CSRFProtect(app)
csrf = CSRFProtect(app)
Environment(app)

if config.DATABASE_ENGINE == "sqlite":
Expand Down Expand Up @@ -80,6 +82,18 @@ def handle_csrf_error(e):
flash(msg, 'error')
return redirect(url_for('main.login'))

def _handle_http_exception(error):
# Workaround for no blueprint-level 404/5 error handlers, see:
# https://github.com/pallets/flask/issues/503#issuecomment-71383286
handler = app.error_handler_spec['api'][error.code].values()[0]
if request.path.startswith('/api/') and handler:
return handler(error)

return render_template('error.html', error=error), error.code

for code in default_exceptions:
app.errorhandler(code)(_handle_http_exception)

i18n.setup_app(config, app)

app.jinja_env.trim_blocks = True
Expand All @@ -97,17 +111,6 @@ def handle_csrf_error(e):
template_filters.rel_datetime_format
app.jinja_env.filters['filesizeformat'] = template_filters.filesizeformat

@app.template_filter('autoversion')
def autoversion_filter(filename):
"""Use this template filter for cache busting"""
absolute_filename = path.join(config.SECUREDROP_ROOT, filename[1:])
if path.exists(absolute_filename):
timestamp = str(path.getmtime(absolute_filename))
else:
return filename
versioned_filename = "{0}?v={1}".format(filename, timestamp)
return versioned_filename

@app.before_request
def setup_g():
"""Store commonly used values in Flask's special g object"""
Expand All @@ -130,8 +133,11 @@ def setup_g():
g.html_lang = i18n.locale_to_rfc_5646(g.locale)
g.locales = i18n.get_locale2name()

if request.endpoint not in _insecure_views and not logged_in():
return redirect(url_for('main.login'))
if request.path.split('/')[1] == 'api':
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think doing request.endpoint.split('.')[0] == 'api' is marginally cleaner. But like. Honestly this is fine too. Just rambling.

pass # We use the @token_required decorator for the API endpoints
else: # We are not using the API
if request.endpoint not in _insecure_views and not logged_in():
return redirect(url_for('main.login'))

if request.method == 'POST':
filesystem_id = request.form.get('filesystem_id')
Expand All @@ -144,5 +150,8 @@ def setup_g():
url_prefix='/account')
app.register_blueprint(admin.make_blueprint(config), url_prefix='/admin')
app.register_blueprint(col.make_blueprint(config), url_prefix='/col')
api_blueprint = api.make_blueprint(config)
app.register_blueprint(api_blueprint, url_prefix='/api/v1')
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually don't think we should attach the API to the main app. They are different enough that I think it would be reasonable to have two applications and use Apache/Nginx to correctly proxy between the two.

That said, we can't really do that now because we don't control the configs.

Copy link
Contributor Author

@redshiftzero redshiftzero Jul 2, 2018

Choose a reason for hiding this comment

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

One of the nice reasons to implement the API as a blueprint registered on the main journalist app is that it's extremely simple to deploy and does not add complexity to an already quite complex ops story. Is there a major advantage to keeping the two separate that would justify the Apache config changes?

Copy link
Contributor

Choose a reason for hiding this comment

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

From an ops perspective (in the context of SD), I think this is the correct thing to do. I just generally like to keep the apps separate when I deploy things because it prevents surprise behavior between different components IMO. Probably should have clarified my initial comment to better say "I don't like this particularly but also what you did is the only sane thing to do."

csrf.exempt(api_blueprint)

return app
Loading