Skip to content

Commit

Permalink
PXP-4931 Handle multiple IDPs + unit tests (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulineribeyre authored Mar 20, 2020
1 parent 3f936d3 commit ac3203b
Show file tree
Hide file tree
Showing 26 changed files with 1,471 additions and 447 deletions.
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ WORKDIR /$appname

RUN python -m pip install --upgrade pip \
&& pip install pipenv \
&& pipenv install --system --deploy
&& pipenv install --system --deploy \
&& pip freeze

RUN mkdir -p /var/www/$appname \
&& mkdir -p /var/www/.cache/Python-Eggs/ \
Expand Down
7 changes: 4 additions & 3 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,28 @@ verify_ssl = true
[dev-packages]
codacy-coverage = "*"
truffleHog = "*"
mock = "~=1.0"
PyGithub = "*"
pytest = ">=3.2.3"
pytest-cov = ">=2.5.1"
pytest-flask = ">=0.10.0"
PyYAML = ">=3.13"

[packages]
alembic = ">=1.4.1"
requests = "*"
flasgger = "*"
flask-cors = "~=3.0"
cdiserrors = "~=0.1"
cdislogging = "~=0.0"
Authlib = "==0.4.1"
Authlib = "==0.11"
cryptography = "~=2.3"
Flask = "~=1.0"
Flask-SQLAlchemy = "~=2.3"
psycopg2 = "~=2.7"
python-jose = "~=3.0"
kubernetes = "~=6.0"
pyyaml = "==4.2b1"
authutils = "*"
authutils = "==4.0.0"

[requires]
python_version = "3.6"
Expand Down
714 changes: 363 additions & 351 deletions Pipfile.lock

Large diffs are not rendered by default.

71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,78 @@ Each type of workspace environment should have a corresponding auth mechanism fo

OpenAPI Specification [here](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/uc-cdis/workspace-token-service/master/openapi/swagger.yaml).

<img src="docs/architecture.svg">

## How a workspace interacts with WTS

- The workspace UI calls `/oauth2/authorization_url` to connect with Fence during user login, this will do an OIDC dance with fence to obtain a refresh token if it's a new user or if the user's previous refresh token is expired.
- The worker calls `/token?expires=seconds` to get an access token


## Why isn't WTS part of Fence?

The `/token` endpoint is [dependent on the local Kubernetes](https://github.com/uc-cdis/workspace-token-service/blob/master/wts/auth_plugins/k8s.py). It trusts the caller ([Gen3Fuse](https://github.com/uc-cdis/gen3-fuse)) to pass the correct user identity.

<img src="docs/img/architecture.svg">


## Gen3 Workspace architecture

[![](docs/img/Export_to_WS_Architecture_Flow.png)](https://www.lucidchart.com/documents/edit/e844ca6b-fb75-460c-8a8e-5ddb4a17b8d9/0_0)


## Configuration

`dbcreds.json`:
```
{
"db_host": "xxx",
"db_username": "xxx",
"db_password": "xxx",
"db_database": "xxx"
}
```

`appcreds.json`:

```
{
"wts_base_url": "https://my-data-commons.net/wts/",
"encryption_key": "xxx",
"secret_key": "xxx",
"fence_base_url": "https://my-data-commons.net/user/",
"oidc_client_id": "xxx",
"oidc_client_secret": "xxx",
"external_oidc": [
{
"base_url": "https://other-data-commons.net",
"oidc_client_id": "xxx",
"oidc_client_secret": "xxx",
"login_options": {
"other-google": {
"name": "Other Commons Google Login",
"params": {
"idp": "google"
}
},
"other-orcid": {
"name": "Other Commons ORCID Login",
"params": {
"idp": "fence",
"fence_idp": "orcid"
}
},
...
}
},
...
]
}
```

The default OIDC client configuration (`fence_base_url`, `oidc_client_id` and `oidc_client_secret`) is generated automatically during `gen3 kube-setup-wts`. Other clients can be created by running the following command in the external Fence: `fence-create client-create --client wts-my-data-commons --urls https://my-data-commons.net/wts/oauth2/authorize --username <your username>`, which returns a `(key id, secret key)` tuple. Any login option that is configured in the external Fence (the list is served at `https://other-data-commons.net/user/login`) can be configured here in the `login_options` section.

Note that IDP IDs (`other-google` and `other-orcid` in the example above) must be unique _across the whole `external_oidc` block_.

Also note that the OIDC clients you create must be granted `read-storage` access to all the data in the external Data Commons.
83 changes: 83 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = migrations

# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =

# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8


[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79

# Logging configuration
[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
Binary file added docs/img/Export_to_WS_Architecture_Flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
98 changes: 98 additions & 0 deletions migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from alembic import context
from logging.config import fileConfig
import json
from sqlalchemy import engine_from_config, pool
from sqlalchemy.engine.url import URL

from wts.models import db
from wts.utils import get_config_var


# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)

target_metadata = db.metadata

postgres_creds = get_config_var("POSTGRES_CREDS_FILE", "")
if postgres_creds:
with open(postgres_creds, "r") as f:
creds = json.load(f)
try:
config.set_main_option(
"sqlalchemy.url",
str(
URL(
drivername="postgresql",
host=creds["db_host"],
port="5432",
username=creds["db_username"],
password=creds["db_password"],
database=creds["db_database"],
)
),
)
except KeyError as e:
print("Postgres creds misconfiguration: {}".format(e))
exit(1)
else:
url = get_config_var("SQLALCHEMY_DATABASE_URI")
if url:
config.set_main_option("sqlalchemy.url", url)
else:
print("Cannot find postgres creds location")
exit(1)


def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
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()
24 changes: 24 additions & 0 deletions migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}
26 changes: 26 additions & 0 deletions migrations/versions/27833deaf81f_add_idp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add IDP
Revision ID: 27833deaf81f
Revises: a38a346e6ded
Create Date: 2020-03-15 19:38:26.321139
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "27833deaf81f"
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
op.add_column("refresh_token", sa.Column("idp", sa.VARCHAR))
op.execute("UPDATE refresh_token SET idp='default'")
op.alter_column("refresh_token", "idp", nullable=False)


def downgrade():
op.drop_column("refresh_token", "idp")
Loading

0 comments on commit ac3203b

Please sign in to comment.