aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.guix/modules/gn-auth.scm3
-rw-r--r--MANIFEST.in2
-rw-r--r--README.md41
-rw-r--r--gn_auth/__init__.py52
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py65
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py10
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py50
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py69
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py59
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py118
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py35
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py6
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py7
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py42
-rw-r--r--gn_auth/auth/authorisation/data/views.py57
-rw-r--r--gn_auth/auth/authorisation/privileges/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/privileges/views.py2
-rw-r--r--gn_auth/auth/authorisation/resources/base.py14
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py37
-rw-r--r--gn_auth/auth/authorisation/resources/common.py24
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py (renamed from gn_auth/auth/authorisation/resources/genotype.py)58
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/views.py78
-rw-r--r--gn_auth/auth/authorisation/resources/groups/data.py12
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py65
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py107
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py96
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py89
-rw-r--r--gn_auth/auth/authorisation/resources/models.py172
-rw-r--r--gn_auth/auth/authorisation/resources/mrna.py9
-rw-r--r--gn_auth/auth/authorisation/resources/phenotype.py68
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/models.py143
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/views.py77
-rw-r--r--gn_auth/auth/authorisation/resources/request_utils.py20
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py21
-rw-r--r--gn_auth/auth/authorisation/resources/views.py185
-rw-r--r--gn_auth/auth/authorisation/roles/models.py47
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py4
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py160
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py14
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py60
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py13
-rw-r--r--gn_auth/auth/authorisation/users/models.py64
-rw-r--r--gn_auth/auth/authorisation/users/views.py326
-rw-r--r--gn_auth/auth/db/mariadb.py45
-rw-r--r--gn_auth/auth/jwks.py86
-rw-r--r--gn_auth/auth/requests.py14
-rw-r--r--gn_auth/auth/views.py2
-rw-r--r--gn_auth/debug.py22
-rw-r--r--gn_auth/errors.py16
-rw-r--r--gn_auth/hooks.py68
-rw-r--r--gn_auth/jobs.py2
-rw-r--r--gn_auth/misc_views.py11
-rw-r--r--gn_auth/session.py4
-rw-r--r--gn_auth/settings.py22
-rw-r--r--gn_auth/smtp.py19
-rw-r--r--gn_auth/static/images/CITGLogo.pngbin0 -> 11962 bytes
-rw-r--r--gn_auth/templates/50x.html25
-rw-r--r--gn_auth/templates/admin/confirm-change-client-secret.html45
-rw-r--r--gn_auth/templates/admin/list-oauth2-clients.html10
-rw-r--r--gn_auth/templates/admin/register-client.html109
-rw-r--r--gn_auth/templates/admin/view-oauth2-client.html136
-rw-r--r--gn_auth/templates/base.html4
-rw-r--r--gn_auth/templates/emails/forgot-password.html38
-rw-r--r--gn_auth/templates/emails/forgot-password.txt12
-rw-r--r--gn_auth/templates/emails/verify-email.html2
-rw-r--r--gn_auth/templates/emails/verify-email.txt2
-rw-r--r--gn_auth/templates/oauth2/authorise-user.html81
-rw-r--r--gn_auth/templates/users/change-password.html52
-rw-r--r--gn_auth/templates/users/forgot-password-token-send-success.html22
-rw-r--r--gn_auth/templates/users/forgot-password.html38
-rw-r--r--gn_auth/templates/users/unverified-user.html148
-rw-r--r--gn_auth/wsgi.py81
-rw-r--r--migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py26
-rw-r--r--migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py24
-rw-r--r--migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py42
-rw-r--r--migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py49
-rw-r--r--migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py19
-rw-r--r--migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py23
-rw-r--r--mypy.ini3
-rw-r--r--scripts/assign_data_to_default_admin.py (renamed from scripts/migrate_existing_data.py)79
-rw-r--r--scripts/batch_assign_data_to_default_admin.py87
-rw-r--r--scripts/link_inbredsets.py6
-rw-r--r--scripts/register_sys_admin.py2
-rw-r--r--scripts/search_phenotypes.py8
-rwxr-xr-xsetup.py24
-rw-r--r--tests/unit/auth/conftest.py30
-rw-r--r--tests/unit/auth/fixtures/group_fixtures.py139
-rw-r--r--tests/unit/auth/fixtures/resource_fixtures.py78
-rw-r--r--tests/unit/auth/fixtures/role_fixtures.py169
-rw-r--r--tests/unit/auth/fixtures/user_fixtures.py25
-rw-r--r--tests/unit/auth/test_groups.py151
-rw-r--r--tests/unit/auth/test_migrations_add_data_to_table.py4
-rw-r--r--tests/unit/auth/test_migrations_add_remove_columns.py4
-rw-r--r--tests/unit/auth/test_migrations_indexes.py4
-rw-r--r--tests/unit/auth/test_migrations_insert_data_into_empty_table.py4
-rw-r--r--tests/unit/auth/test_privileges.py11
-rw-r--r--tests/unit/auth/test_resources.py51
-rw-r--r--tests/unit/auth/test_resources_roles.py90
-rw-r--r--tests/unit/auth/test_roles.py322
-rw-r--r--tests/unit/conftest.py13
106 files changed, 3811 insertions, 1391 deletions
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm
index 4c51f96..0dab8d9 100644
--- a/.guix/modules/gn-auth.scm
+++ b/.guix/modules/gn-auth.scm
@@ -34,8 +34,7 @@
#~(modify-phases #$phases
(add-before 'build 'pylint
(lambda _
- (invoke "pylint" "main.py" "setup.py" "wsgi.py"
- "tests" "gn_auth" "scripts")))
+ (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts")))
(add-after 'pylint 'mypy
(lambda _
(invoke "mypy" ".")))))))
diff --git a/MANIFEST.in b/MANIFEST.in
index ea5197f..afcd2a8 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,2 @@
-global-include static/**/*.js static/**/*.css templates/**/*.html
+global-include static/**/*.js static/**/*.css templates/**/*.html templates/**/*.txt
global-exclude *~ *.py[cod] \ No newline at end of file
diff --git a/README.md b/README.md
index f4f0d48..4146493 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ The recommended way to pass configuration values to the application is via a
configuration file passed in via the `GN_AUTH_CONF` environment variable. This
variable simply holds the path to the configuration file, e.g.
```sh
-export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.py"
+export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.conf"
```
The settings in the file above will override
@@ -146,7 +146,7 @@ $ yoyo new -m "<description of the migration>" ./migrations/auth/
The command will ask whether you want to save the migration configuration, e.g.
```bash
-$ yoyo new --config=yoyo.auth.ini -m "testing a new migration"
+$ yoyo new --database="sqlite:////tmp/test-auth.db" --migration-table=_yoyo_migration -m "testing a new migration" ./migrations/auth/
Error: could not open editor!
Created file ./migrations/auth/20221103_02_HBzwk-testing-a-new-migration.py
Save migration configuration to yoyo.ini?
@@ -186,7 +186,7 @@ If you have previously initialised the yoyo config file, you can put the databas
As a convenience, and to enable the CI/CD to apply the migrations automatically, I have provided a flask cli command that can be run with:
```bash
-$ export FLASK_APP=main.py
+$ export FLASK_APP=wsgi.py
$ flask apply-migrations
```
@@ -205,17 +205,46 @@ following environment variable(s):
### Development
+For initial set up, you need a custom configuration file that will contain
+custom local_settings. At minimum it can contain:
+
+```python
+# contents for local_settings saved at /absolute/path/to/local_settings_file.conf
+SQL_URI = "mysql://user:password@localhost/db_name" # mysql uri
+AUTH_DB = "/absolute/path/to/auth.db/" # path to sqlite db file
+# path to file containings SECRETS key.
+# Note: this path is also used to determine the jwks location
+GN_AUTH_SECRETS = "/home/rookie/gn_data/gn2_files/secrets.conf"
+```
+
+Here's an example `secrets.conf` file:
+
+```python
+SECRET_KEY = "qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk"
+```
+
+and you set up the oauth clients using:
+
+```
+export FLASK_DEBUG=1 AUTHLIB_INSECURE_TRANSPORT=1 OAUTHLIB_INSECURE_TRANSPORT=1 FLASK_APP=gn_auth/wsgi
+export GN_AUTH_CONF=/absolute/path/to/local_settings_file.conf
+# this sets up a user and client
+flask init-dev-clients --client-uri http://localhost:gn2_port_number
+```
+
To run the application during development:
```sh
export FLASK_DEBUG=1
-export FLASK_APP="main.py"
+export FLASK_APP="wsgi.py"
export AUTHLIB_INSECURE_TRANSPORT=true
-export GN_AUTH_CONF="${HOME}/genenetwork/configs/gn_auth_conf.py"
+export GN_AUTH_CONF="/absolute/path/to/local_settings_file.conf"
flask run --port=8081
```
-replace the `GN_AUTH_CONF` file with the correct path for your environment.
+You can test this by attemptiong to log in to your local GN2 using credentials
+defined by the dummy user you set up (see the function `__init_dev_users__` in
+`/gn_auth/wsgi.py`).
### Production
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py
index b3df070..3b663dc 100644
--- a/gn_auth/__init__.py
+++ b/gn_auth/__init__.py
@@ -1,6 +1,7 @@
"""Application initialisation module."""
import os
import sys
+import logging
from pathlib import Path
from typing import Optional, Callable
@@ -8,6 +9,7 @@ from flask import Flask
from flask_cors import CORS
from authlib.jose import JsonWebKey
+from gn_auth import hooks
from gn_auth.misc_views import misc
from gn_auth.auth.views import oauth2
@@ -24,7 +26,7 @@ def check_mandatory_settings(app: Flask) -> None:
undefined = tuple(
setting for setting in (
"SECRET_KEY", "SQL_URI", "AUTH_DB", "AUTH_MIGRATIONS",
- "OAUTH2_SCOPE", "SSL_PRIVATE_KEY", "CLIENTS_SSL_PUBLIC_KEYS_DIR")
+ "OAUTH2_SCOPES_SUPPORTED")
if not ((setting in app.config) and bool(app.config[setting])))
if len(undefined) > 0:
raise ConfigurationError(
@@ -51,26 +53,38 @@ def load_secrets_conf(app: Flask) -> None:
app.config.from_pyfile(secretsfile)
-def parse_ssl_keys(app):
- """Parse the SSL keys."""
- def __parse_key__(keypath: Path) -> JsonWebKey:
- with open(keypath) as _sslkey:# pylint: disable=[unspecified-encoding]
- return JsonWebKey.import_key(_sslkey.read())
+def dev_loggers(appl: Flask) -> None:
+ """Setup the logging handlers."""
+ stderr_handler = logging.StreamHandler(stream=sys.stderr)
+ appl.logger.addHandler(stderr_handler)
- key_storage_dir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"])
- key_storage_dir.mkdir(exist_ok=True)
- app.config["SSL_PUBLIC_KEYS"] = {
- _key.as_dict()["kid"]: _key for _key in (
- __parse_key__(Path(key_storage_dir).joinpath(key))
- for key in os.listdir(key_storage_dir))}
+ root_logger = logging.getLogger()
+ root_logger.addHandler(stderr_handler)
+ root_logger.setLevel(appl.config["LOGLEVEL"])
- app.config["SSL_PRIVATE_KEY"] = __parse_key__(
- Path(app.config["SSL_PRIVATE_KEY"]))
-def create_app(
- config: Optional[dict] = None,
- setup_logging: Callable[[Flask], None] = lambda appl: None
-) -> Flask:
+def gunicorn_loggers(appl: Flask) -> None:
+ """Use gunicorn logging handlers for the application."""
+ logger = logging.getLogger("gunicorn.error")
+ appl.logger.handlers = logger.handlers
+ appl.logger.setLevel(logger.level)
+
+
+def setup_logging(appl: Flask) -> None:
+ """
+ Setup the loggers according to the WSGI server used to run the application.
+ """
+ # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
+ # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
+ # https://peps.python.org/pep-3333/#id4
+ software, *_version_and_comments = os.environ.get(
+ "SERVER_SOFTWARE", "").split('/')
+ if bool(software):
+ gunicorn_loggers(appl)
+ dev_loggers(appl)
+
+
+def create_app(config: Optional[dict] = None) -> Flask:
"""Create and return a new flask application."""
app = Flask(__name__)
@@ -85,7 +99,6 @@ def create_app(
override_settings_with_envvars(app)
load_secrets_conf(app)
- parse_ssl_keys(app)
# ====== END: Setup configuration ======
setup_logging(app)
@@ -104,5 +117,6 @@ def create_app(
app.register_blueprint(oauth2, url_prefix="/auth")
register_error_handlers(app)
+ hooks.register_hooks(app)
return app
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 572324e..200b25d 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -20,6 +20,7 @@ def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argu
class IntrospectionEndpoint(_IntrospectionEndpoint):
"""Introspect token."""
+ CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
return _query_token(self, token_string, token_type_hint)
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 240ca30..80922f1 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -12,6 +12,7 @@ from .utilities import query_token as _query_token
class RevocationEndpoint(_RevocationEndpoint):
"""Revoke the tokens"""
ENDPOINT_NAME = "revoke"
+ CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
return _query_token(self, token_string, token_type_hint)
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index b0f2cc7..c802091 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,15 +1,21 @@
"""JWT as Authorisation Grant"""
import uuid
+import time
+from typing import Optional
from flask import current_app as app
+from authlib.jose import jwt
+from authlib.common.encoding import to_native
from authlib.common.security import generate_token
from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
+from gn_auth.debug import __pk__
from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.users import User, user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
@@ -19,23 +25,66 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
DEFAULT_EXPIRES_IN = 300
- def get_token_data(#pylint: disable=[too-many-arguments]
+ def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, expires_in=None, user=None, scope=None
):
"""Post process data to prevent JSON serialization problems."""
- tokendata = super().get_token_data(
- grant_type, client, expires_in, user, scope)
+ issued_at = int(time.time())
+ tokendata = {
+ "scope": self.get_allowed_scope(client, scope),
+ "grant_type": grant_type,
+ "iat": issued_at,
+ "client_id": client.get_client_id()
+ }
+ if isinstance(expires_in, int) and expires_in > 0:
+ tokendata["exp"] = issued_at + expires_in
+ if self.issuer:
+ tokendata["iss"] = self.issuer
+ if user:
+ tokendata["sub"] = self.get_sub_value(user)
+
return {
**{
key: str(value) if key.endswith("_id") else value
for key, value in tokendata.items()
},
"sub": str(tokendata["sub"]),
- "jti": str(uuid.uuid4())
+ "jti": str(uuid.uuid4()),
+ "oauth2_client_id": str(client.client_id)
}
+ def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ self,
+ grant_type: str,
+ client: OAuth2Client,
+ user: Optional[User] = None,
+ scope: Optional[str] = None,
+ expires_in: Optional[int] = None
+ ) -> dict:
+ """Generate a bearer token for OAuth 2.0 authorization token endpoint.
+
+ :param client: the client that making the request.
+ :param grant_type: current requested grant_type.
+ :param user: current authorized user.
+ :param expires_in: if provided, use this value as expires_in.
+ :param scope: current requested scope.
+ :return: Token dict
+ """
+
+ token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
+ access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False)
+ token = {
+ "token_type": "Bearer",
+ "access_token": to_native(access_token)
+ }
+ if expires_in:
+ token["expires_in"] = expires_in
+ if scope:
+ token["scope"] = scope
+ return token
+
- def __call__(# pylint: disable=[too-many-arguments]
+ def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, user=None, scope=None, expires_in=None,
include_refresh_token=True
):
@@ -74,7 +123,9 @@ class JWTBearerGrant(_JWTBearerGrant):
def resolve_client_key(self, client, headers, payload):
"""Resolve client key to decode assertion data."""
- return app.config["SSL_PUBLIC_KEYS"].get(headers["kid"])
+ keyset = client.jwks()
+ __pk__("THE KEYSET =======>", keyset.keys)
+ return keyset.find_by_kid(headers["kid"])
def authenticate_user(self, subject):
diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
index fd6804d..f897d89 100644
--- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
@@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
else Nothing)
).maybe(None, lambda _tok: _tok)
- def authenticate_user(self, credential):
+ def authenticate_user(self, refresh_token):
"""Check that user is valid for given token."""
with connection(app.config["AUTH_DB"]) as conn:
try:
- return user_by_id(conn, credential.user.user_id)
+ return user_by_id(conn, refresh_token.user.user_id)
except NotFoundError as _nfe:
return None
return None
- def revoke_old_credential(self, credential):
+ def revoke_old_credential(self, refresh_token):
"""Revoke any old refresh token after issuing new refresh token."""
with connection(app.config["AUTH_DB"]) as conn:
- if credential.parent_of is not None:
- revoke_refresh_token(conn, credential)
+ if refresh_token.parent_of is not None:
+ revoke_refresh_token(conn, refresh_token)
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
new file mode 100644
index 0000000..71769e1
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -0,0 +1,50 @@
+"""Implement model for JWTBearerToken"""
+import uuid
+import time
+from typing import Optional
+
+from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
+
+from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+ client as fetch_client)
+
+class JWTBearerToken(_JWTBearerToken):
+ """Overrides default JWTBearerToken class."""
+
+ def __init__(self, payload, header, options=None, params=None):
+ """Initialise the bearer token."""
+ # TOD0: Maybe remove this init and make this a dataclass like the way
+ # OAuth2Client is a dataclass
+ super().__init__(payload, header, options, params)
+ self.user = with_db_connection(
+ lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ self.client = with_db_connection(
+ lambda conn: fetch_client(
+ conn, uuid.UUID(payload["oauth2_client_id"])
+ )
+ ).maybe(None, lambda _client: _client)
+
+
+ def check_client(self, client):
+ """Check that the client is right."""
+ return self.client.get_client_id() == client.get_client_id()
+
+
+ def get_expires_in(self) -> Optional[int]:
+ """Return the number of seconds the token is valid for since issue.
+
+ If `None`, the token never expires."""
+ if "exp" in self:
+ return self['exp'] - self['iat']
+ return None
+
+
+ def is_expired(self):
+ """Check whether the token is expired.
+
+ If there is no 'exp' member, assume this token will never expire."""
+ if "exp" in self:
+ return self["exp"] < time.time()
+ return False
diff --git a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
index 31c9147..46515c8 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
@@ -142,7 +142,7 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str):
"WHERE token=:parenttoken"),
{"parenttoken": parent.token, "childtoken": childtoken})
- def __check_child__(parent):
+ def __check_child__(parent):#pylint: disable=[unused-variable]
with db.cursor(conn) as cursor:
cursor.execute(
("SELECT * FROM jwt_refresh_tokens WHERE token=:parenttoken"),
@@ -154,15 +154,17 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str):
"activity detected.")
return Right(parent)
- def __revoke_and_raise_error__(_error_msg_):
+ def __revoke_and_raise_error__(_error_msg_):#pylint: disable=[unused-variable]
load_refresh_token(conn, parenttoken).then(
lambda _tok: revoke_refresh_token(conn, _tok))
raise InvalidGrantError(_error_msg_)
+ def __handle_not_found__(_error_msg_):
+ raise InvalidGrantError(_error_msg_)
+
load_refresh_token(conn, parenttoken).maybe(
- Left("Token not found"), Right).then(
- __check_child__).either(__revoke_and_raise_error__,
- __link_to_child__)
+ Left("Token not found"), Right).either(
+ __handle_not_found__, __link_to_child__)
def is_refresh_token_valid(token: JWTRefreshToken, client: OAuth2Client) -> bool:
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index d31faf6..1639e2e 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,17 +1,19 @@
"""OAuth2 Client model."""
import json
import datetime
-from pathlib import Path
-
from uuid import UUID
-from dataclasses import dataclass
from functools import cached_property
-from typing import Sequence, Optional
+from dataclasses import asdict, dataclass
+from typing import Any, Sequence, Optional
+import requests
+from flask import current_app as app
+from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
from pymonad.maybe import Just, Maybe, Nothing
+from gn_auth.debug import __pk__
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import (User,
@@ -57,16 +59,34 @@ class OAuth2Client(ClientMixin):
"""
return self.client_metadata.get("client_type", "public")
- @cached_property
+
def jwks(self) -> KeySet:
"""Return this client's KeySet."""
- def __parse_key__(keypath: Path) -> JsonWebKey:
- with open(keypath) as _key:# pylint: disable=[unspecified-encoding]
- return JsonWebKey.import_key(_key.read())
+ jwksuri = self.client_metadata.get("public-jwks-uri")
+ __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
+ if not bool(jwksuri):
+ app.logger.debug("No Public JWKs URI set for client!")
+ return KeySet([])
+ try:
+ ## IMPORTANT: This can cause a deadlock if the client is working in
+ ## single-threaded mode, i.e. can only serve one request
+ ## at a time.
+ return KeySet([JsonWebKey.import_key(key)
+ for key in requests.get(
+ jwksuri,
+ timeout=300,
+ allow_redirects=True).json()["jwks"]])
+ except requests.ConnectionError as _connerr:
+ app.logger.debug(
+ "Could not connect to provided URI: %s", jwksuri, exc_info=True)
+ except JSONDecodeError as _jsonerr:
+ app.logger.debug(
+ "Could not convert response to JSON", exc_info=True)
+ except Exception as _exc:# pylint: disable=[broad-except]
+ app.logger.debug(
+ "Error retrieving the JWKs for the client.", exc_info=True)
+ return KeySet([])
- return KeySet([
- __parse_key__(Path(pth))
- for pth in self.client_metadata.get("public_keys", [])])
def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool:
"""
@@ -77,12 +97,9 @@ class OAuth2Client(ClientMixin):
* client_secret_post: Client uses the HTTP POST parameters
* client_secret_basic: Client uses HTTP Basic
"""
- if endpoint == "token":
+ if endpoint in ("token", "revoke", "introspection"):
return (method in self.token_endpoint_auth_method
and method == "client_secret_post")
- if endpoint in ("introspection", "revoke"):
- return (method in self.token_endpoint_auth_method
- and method == "client_secret_basic")
return False
@cached_property
@@ -277,3 +294,25 @@ def delete_client(
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
+
+
+def update_client_attribute(
+ client: OAuth2Client,# pylint: disable=[redefined-outer-name]
+ attribute: str,
+ value: Any
+) -> OAuth2Client:
+ """Return a new OAuth2Client with the given attribute updated/changed."""
+ attrs = {
+ attr: type(value)
+ for attr, value in asdict(client).items()
+ if attr != "client_id"
+ }
+ assert (
+ attribute in attrs.keys() and isinstance(value, attrs[attribute])), (
+ "Invalid attribute/value provided!")
+ return OAuth2Client(
+ client_id=client.client_id,
+ **{
+ attr: (value if attr==attribute else getattr(client, attr))
+ for attr in attrs
+ })
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 2405ee2..8ecf923 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,11 +1,20 @@
"""Protect the resources endpoints"""
+from datetime import datetime, timezone, timedelta
from flask import current_app as app
+
+from authlib.jose import jwt, KeySet, JoseError
from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
+from authlib.oauth2.rfc7523 import (
+ JWTBearerTokenValidator as _JWTBearerTokenValidator)
from authlib.integrations.flask_oauth2 import ResourceProtector
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.authentication.oauth2.models.oauth2token import token_by_access_token
+from gn_auth.auth.jwks import list_jwks, jwks_directory
+from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
+ JWTBearerToken)
+from gn_auth.auth.authentication.oauth2.models.oauth2token import (
+ token_by_access_token)
class BearerTokenValidator(_BearerTokenValidator):
"""Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
@@ -14,4 +23,52 @@ class BearerTokenValidator(_BearerTokenValidator):
return token_by_access_token(conn, token_string).maybe(# type: ignore[misc]
None, lambda tok: tok)
+class JWTBearerTokenValidator(_JWTBearerTokenValidator):
+ """Validate a token using all the keys"""
+ token_cls = JWTBearerToken
+ _local_attributes = ("jwt_refresh_frequency_hours",)
+
+ def __init__(self, public_key, issuer=None, realm=None, **extra_attributes):
+ """Initialise the validator class."""
+ # https://docs.authlib.org/en/latest/jose/jwt.html#use-dynamic-keys
+ # We can simply use the KeySet rather than a specific key.
+ super().__init__(public_key,
+ issuer,
+ realm,
+ **{
+ key: value for key,value
+ in extra_attributes.items()
+ if key not in self._local_attributes
+ })
+ self._last_jwks_update = datetime.now(tz=timezone.utc)
+ self._refresh_frequency = timedelta(hours=int(
+ extra_attributes.get("jwt_refresh_frequency_hours", 6)))
+ self.claims_options = {
+ 'exp': {'essential': False},
+ 'client_id': {'essential': True},
+ 'grant_type': {'essential': True},
+ }
+
+ def __refresh_jwks__(self):
+ now = datetime.now(tz=timezone.utc)
+ if (now - self._last_jwks_update) >= self._refresh_frequency:
+ self.public_key = KeySet(list_jwks(jwks_directory(app)))
+
+ def authenticate_token(self, token_string: str):
+ self.__refresh_jwks__()
+ for key in self.public_key.keys:
+ try:
+ claims = jwt.decode(
+ token_string, key,
+ claims_options=self.claims_options,
+ claims_cls=self.token_cls,
+ )
+ claims.validate()
+ return claims
+ except JoseError as error:
+ app.logger.debug('Authenticate token failed. %r', error)
+
+ return None
+
+
require_oauth = ResourceProtector()
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index d845c60..8ac5106 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -1,23 +1,24 @@
"""Initialise the OAuth2 Server"""
import uuid
-import datetime
from typing import Callable
+from datetime import datetime
-from flask import Flask, current_app
-from authlib.jose import jwk, jwt
-from authlib.oauth2.rfc7523 import JWTBearerTokenValidator
+from flask import Flask, current_app, request as flask_request
+from authlib.jose import KeySet
+from authlib.oauth2.rfc6749 import OAuth2Request
from authlib.oauth2.rfc6749.errors import InvalidClientError
from authlib.integrations.flask_oauth2 import AuthorizationServer
+from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.jwks import (
+ list_jwks,
+ jwks_directory,
+ newest_jwk_with_rotation)
+from .models.jwt_bearer_token import JWTBearerToken
from .models.oauth2client import client as fetch_client
from .models.oauth2token import OAuth2Token, save_token
-from .models.jwtrefreshtoken import (
- JWTRefreshToken,
- link_child_token,
- save_refresh_token,
- load_refresh_token)
from .grants.password_grant import PasswordGrant
from .grants.refresh_token_grant import RefreshTokenGrant
@@ -27,7 +28,9 @@ from .grants.jwt_bearer_grant import JWTBearerGrant, JWTBearerTokenGenerator
from .endpoints.revocation import RevocationEndpoint
from .endpoints.introspection import IntrospectionEndpoint
-from .resource_server import require_oauth, BearerTokenValidator
+from .resource_server import require_oauth, JWTBearerTokenValidator
+
+_TWO_HOURS_ = 2 * 60 * 60
def create_query_client_func() -> Callable:
@@ -45,52 +48,32 @@ def create_query_client_func() -> Callable:
return __query_client__
-def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable:
+def create_save_token_func(token_model: type) -> Callable:
"""Create the function that saves the token."""
+ def __ignore_token__(token, request):# pylint: disable=[unused-argument]
+ """Ignore the token: i.e. Do not save it."""
+
def __save_token__(token, request):
- _jwt = jwt.decode(token["access_token"], jwtkey)
- _token = token_model(
- token_id=uuid.UUID(_jwt["jti"]),
- client=request.client,
- user=request.user,
- **{
- "refresh_token": None,
- "revoked": False,
- "issued_at": datetime.datetime.now(),
- **token
- })
with db.connection(current_app.config["AUTH_DB"]) as conn:
- save_token(conn, _token)
- old_refresh_token = load_refresh_token(
+ save_token(
conn,
- request.form.get("refresh_token", "nosuchtoken")
- )
- new_refresh_token = JWTRefreshToken(
- token=_token.refresh_token,
+ token_model(
+ **token,
+ token_id=uuid.uuid4(),
client=request.client,
user=request.user,
- issued_with=uuid.UUID(_jwt["jti"]),
- issued_at=datetime.datetime.fromtimestamp(_jwt["iat"]),
- expires=datetime.datetime.fromtimestamp(
- old_refresh_token.then(
- lambda _tok: _tok.expires.timestamp()
- ).maybe((int(_jwt["iat"]) +
- RefreshTokenGrant.DEFAULT_EXPIRES_IN),
- lambda _expires: _expires)),
- scope=_token.get_scope(),
+ issued_at=datetime.now(),
revoked=False,
- parent_of=None)
- save_refresh_token(conn, new_refresh_token)
- old_refresh_token.then(lambda _tok: link_child_token(
- conn, _tok.token, new_refresh_token.token))
-
- return __save_token__
+ expires_in=_TWO_HOURS_))
+ return {
+ OAuth2Token: __save_token__,
+ JWTBearerToken: __ignore_token__
+ }[token_model]
def make_jwt_token_generator(app):
"""Make token generator function."""
- _gen = JWTBearerTokenGenerator(app.config["SSL_PRIVATE_KEY"])
- def __generator__(# pylint: disable=[too-many-arguments]
+ def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
grant_type,
client,
user=None,
@@ -98,19 +81,42 @@ def make_jwt_token_generator(app):
expires_in=None,# pylint: disable=[unused-argument]
include_refresh_token=True
):
- return _gen.__call__(
- grant_type,
- client,
- user,
- scope,
- JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN,
- include_refresh_token)
+ return JWTBearerTokenGenerator(
+ secret_key=newest_jwk_with_rotation(
+ jwks_directory(app),
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])),
+ issuer=flask_request.host_url,
+ alg="RS256").__call__(
+ grant_type=grant_type,
+ client=client,
+ user=user,
+ scope=scope,
+ expires_in=expires_in,
+ include_refresh_token=include_refresh_token)
return __generator__
+
+class JsonAuthorizationServer(AuthorizationServer):
+ """An authorisation server using JSON rather than FORMDATA."""
+
+ def create_oauth2_request(self, request):
+ """Create an OAuth2 Request from the flask request."""
+ match flask_request.headers.get("Content-Type"):
+ case "application/json":
+ req = OAuth2Request(flask_request.method,
+ flask_request.url,
+ flask_request.get_json(),
+ flask_request.headers)
+ case _:
+ req = FlaskOAuth2Request(flask_request)
+
+ return req
+
+
def setup_oauth2_server(app: Flask) -> None:
"""Set's up the oauth2 server for the flask application."""
- server = AuthorizationServer()
+ server = JsonAuthorizationServer()
server.register_grant(PasswordGrant)
# Figure out a common `code_verifier` for GN2 and GN3 and set
@@ -133,11 +139,9 @@ def setup_oauth2_server(app: Flask) -> None:
server.init_app(
app,
query_client=create_query_client_func(),
- save_token=create_save_token_func(
- OAuth2Token, app.config["SSL_PRIVATE_KEY"]))
+ save_token=create_save_token_func(JWTBearerToken))
app.config["OAUTH2_SERVER"] = server
## Set up the token validators
- require_oauth.register_token_validator(BearerTokenValidator())
require_oauth.register_token_validator(
- JWTBearerTokenValidator(app.config["SSL_PRIVATE_KEY"].get_public_key()))
+ JWTBearerTokenValidator(KeySet(list_jwks(jwks_directory(app)))))
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index fc27768..0e2c4eb 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -9,6 +9,7 @@ from flask import (
flash,
request,
url_for,
+ jsonify,
redirect,
Response,
Blueprint,
@@ -17,6 +18,7 @@ from flask import (
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.jwks import jwks_directory, list_jwks
from gn_auth.auth.errors import NotFoundError, ForbiddenAccess
from gn_auth.auth.authentication.users import valid_login, user_by_email
@@ -37,14 +39,22 @@ def authorise():
"""Authorise a user"""
try:
server = app.config["OAUTH2_SERVER"]
- client_id = uuid.UUID(request.args.get(
- "client_id",
- request.form.get("client_id", str(uuid.uuid4()))))
+ client_id = uuid.UUID(request.args.get("client_id")
+ or request.form.get("client_id")
+ or str(uuid.uuid4()))
client = server.query_client(client_id)
if not bool(client):
flash("Invalid OAuth2 client.", "alert-danger")
if request.method == "GET":
+ def __forgot_password_table_exists__(conn):
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT name FROM sqlite_master "
+ "WHERE type='table' "
+ "AND name='forgot_password_tokens'")
+ return bool(cursor.fetchone())
+ return False
+
client = server.query_client(request.args.get("client_id"))
_src = urlparse(request.args["redirect_uri"])
return render_template(
@@ -53,7 +63,9 @@ def authorise():
scope=client.scope,
response_type=request.args["response_type"],
redirect_uri=request.args["redirect_uri"],
- source_uri=f"{_src.scheme}://{_src.netloc}/")
+ source_uri=f"{_src.scheme}://{_src.netloc}/",
+ display_forgot_password=with_db_connection(
+ __forgot_password_table_exists__))
form = request.form
def __authorise__(conn: db.DbConnection):
@@ -65,14 +77,15 @@ def authorise():
try:
email = validate_email(
form.get("user:email"), check_deliverability=False)
- user = user_by_email(conn, email["email"])
+ user = user_by_email(conn, email["email"]) # type: ignore
if valid_login(conn, user, form.get("user:password", "")):
if not user.verified:
return redirect(
url_for("oauth2.users.handle_unverified",
response_type=form["response_type"],
client_id=client_id,
- redirect_uri=form["redirect_uri"]),
+ redirect_uri=form["redirect_uri"],
+ email=email["email"]),
code=307)
return server.create_authorization_response(request=request, grant_user=user)
flash(email_passwd_msg, "alert-danger")
@@ -116,3 +129,13 @@ def introspect_token() -> Response:
IntrospectionEndpoint.ENDPOINT_NAME)
raise ForbiddenAccess("You cannot access this endpoint")
+
+
+@auth.route("/public-jwks", methods=["GET"])
+def public_jwks():
+ """Provide the JWK public keys used by this application."""
+ return jsonify({
+ "documentation": (
+ "The keys are listed in order of creation, from the oldest (first) "
+ "to the newest (last)."),
+ "jwks": tuple(key.as_dict() for key in list_jwks(jwks_directory(app)))})
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index bdab8fa..ddb0add 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -3,9 +3,9 @@ import uuid
from dataclasses import asdict
from typing import Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.authorisation.checks import authorised_p
@@ -22,8 +22,8 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 60470a7..0cc644e 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -2,10 +2,11 @@
import uuid
from dataclasses import asdict
from typing import Iterable
+
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
@@ -21,8 +22,8 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 0a76237..3e45af3 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -3,16 +3,20 @@ import uuid
from dataclasses import asdict
from typing import Any, Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
+from gn_auth.auth.errors import AuthorisationError
from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
def linked_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
species: str = "") -> Iterable[dict[str, Any]]:
"""Retrieve phenotype data linked to user groups."""
authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId")
@@ -53,7 +57,7 @@ def linked_phenotype_data(
"group(s)."),
oauth2_scope="profile group resource")
def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection):
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
"""Retrieve phenotype data that is not linked to any user group."""
with gn3conn.cursor() as cursor:
params = tuple(
@@ -83,7 +87,7 @@ def ungrouped_phenotype_data(
return tuple()
-def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[dict, ...]:
+def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
if len(params) < 1:
return tuple()
@@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[d
for itm in sublist))
return cursor.fetchall()
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
+
def link_phenotype_data(
- authconn:authdb.DbConnection, gn3conn: gn3db.DbConnection, group: Group,
- traits: tuple[dict, ...]) -> dict:
+ authconn: authdb.DbConnection,
+ user,
+ group: Group,
+ traits: tuple[dict, ...]
+) -> dict:
"""Link phenotype traits to a user group."""
+ if not (authorised_for2(authconn,
+ user,
+ system_resource(authconn),
+ ("system:data:link-to-group",))
+ or
+ authorised_for2(authconn,
+ user,
+ group_resource(authconn, group.group_id),
+ ("group:data:link-to-group",))
+ ):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to link data to group "
+ f"'{group.group_name}'.")
with authdb.cursor(authconn) as cursor:
params = tuple({
"data_link_id": str(uuid.uuid4()),
"group_id": str(group.group_id),
**item
- } for item in __traits__(gn3conn, traits))
+ } for item in traits)
cursor.executemany(
"INSERT INTO linked_phenotype_data "
"VALUES ("
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 83f4e4b..9123949 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -11,14 +11,17 @@ from MySQLdb.cursors import DictCursor
from authlib.integrations.flask_oauth2.errors import _HTTPException
from flask import request, jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import mysqldb as gn3db
+
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
+from gn_auth.auth.requests import request_json
from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
from ...db import sqlite3 as db
-from ...db import mariadb as gn3db
from ...db.sqlite3 import with_db_connection
from ..checks import require_json
@@ -32,8 +35,8 @@ from ..resources.models import (
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.phenotypes import link_phenotype_data
from ..data.mrna import link_mrna_data, ungrouped_mrna_data
+from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
data = Blueprint("data", __name__)
@@ -184,18 +187,18 @@ def __search_mrna__():
return jsonify(with_db_connection(__ungrouped__))
def __request_key__(key: str, default: Any = ""):
- if bool(request.json):
- return request.json.get(#type: ignore[union-attr]
- key, request.args.get(key, request.form.get(key, default)))
- return request.args.get(key, request.form.get(key, default))
+ if bool(request_json()):
+ return request_json().get(#type: ignore[union-attr]
+ key, request.args.get(key, default))
+ return request.args.get(key, request_json().get(key, default))
def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()):
- if bool(request.json):
- return (request.json.get(key,[])#type: ignore[union-attr]
- or request.args.getlist(key) or request.form.getlist(key)
+ if bool(request_json()):
+ return (request_json().get(key,[])#type: ignore[union-attr]
+ or request.args.getlist(key) or request_json().get(key)
or list(default))
return (request.args.getlist(key)
- or request.form.getlist(key) or list(default))
+ or request_json().get(key) or list(default))
def __search_genotypes__():
query = __request_key__("query", "")
@@ -240,7 +243,7 @@ def __search_phenotypes__():
@require_oauth("profile group resource")
def search_unlinked_data():
"""Search for various unlinked data."""
- dataset_type = request.json["dataset_type"]
+ dataset_type = request_json()["dataset_type"]
search_fns = {
"mrna": __search_mrna__,
"genotype": __search_genotypes__,
@@ -281,7 +284,7 @@ def link_genotypes() -> Response:
return link_genotype_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
- partial(__link__, **__values__(request.json))))
+ partial(__link__, **__values__(request_json()))))
@data.route("/link/mrna", methods=["POST"])
def link_mrna() -> Response:
@@ -306,9 +309,10 @@ def link_mrna() -> Response:
return link_mrna_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
- partial(__link__, **__values__(request.json))))
+ partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
+@require_oauth("profile group resource")
def link_phenotype() -> Response:
"""Link phenotype data to group."""
def __values__(form):
@@ -324,14 +328,27 @@ def link_phenotype() -> Response:
raise InvalidData("Expected at least one dataset to be provided.")
return {
"group_id": uuid.UUID(form["group_id"]),
- "traits": form["selected"]
+ "traits": form["selected"],
+ "using_raw_ids": bool(form.get("using-raw-ids") == "on")
}
- with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn:
- def __link__(conn: db.DbConnection, group_id: uuid.UUID,
- traits: tuple[dict, ...]) -> dict:
- return link_phenotype_data(
- conn, gn3conn, group_by_id(conn, group_id), traits)
+ with (require_oauth.acquire("profile group resource") as token,
+ gn3db.database_connection(app.config["SQL_URI"]) as gn3conn):
+ def __link__(
+ conn: db.DbConnection,
+ group_id: uuid.UUID,
+ traits: tuple[dict, ...],
+ using_raw_ids: bool = False
+ ) -> dict:
+ if using_raw_ids:
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ traits)
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
- partial(__link__, **__values__(request.json))))
+ partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/privileges/__init__.py b/gn_auth/auth/authorisation/privileges/__init__.py
index 18c7f5d..c99031d 100644
--- a/gn_auth/auth/authorisation/privileges/__init__.py
+++ b/gn_auth/auth/authorisation/privileges/__init__.py
@@ -1,5 +1,6 @@
"""Package for Privileges."""
from .models import (Privilege,
user_privileges,
+ privilege_by_id,
privileges_by_ids,
db_row_to_privilege)
diff --git a/gn_auth/auth/authorisation/privileges/views.py b/gn_auth/auth/authorisation/privileges/views.py
index d50e5cb..75ac8e3 100644
--- a/gn_auth/auth/authorisation/privileges/views.py
+++ b/gn_auth/auth/authorisation/privileges/views.py
@@ -1,6 +1,4 @@
"""Routes for privileges."""
-from dataclasses import asdict
-
from werkzeug.exceptions import NotFound
from flask import jsonify, Blueprint, current_app as app
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index ac93049..333ba0d 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -3,6 +3,8 @@ from uuid import UUID
from dataclasses import dataclass
from typing import Any, Sequence
+import sqlite3
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -20,3 +22,15 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+
+
+def resource_from_dbrow(row: sqlite3.Row):
+ """Convert an SQLite3 resultset row into a resource."""
+ return Resource(
+ resource_id=UUID(row["resource_id"]),
+ resource_name=row["resource_name"],
+ resource_category=ResourceCategory(
+ UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ public=bool(int(row["public"])))
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index d8e3a9f..5484dbf 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -3,9 +3,13 @@ from uuid import UUID
from functools import reduce
from typing import Sequence
+from .base import Resource
+
from ...db import sqlite3 as db
from ...authentication.users import User
+from ..privileges.models import db_row_to_privilege
+
def __organise_privileges_by_resource_id__(rows):
def __organise__(privs, row):
resource_id = UUID(row["resource_id"])
@@ -16,6 +20,7 @@ def __organise_privileges_by_resource_id__(rows):
}
return reduce(__organise__, rows, {})
+
def authorised_for(conn: db.DbConnection,
user: User,
privileges: tuple[str, ...],
@@ -45,3 +50,35 @@ def authorised_for(conn: db.DbConnection,
resource_id: resource_id in authorised
for resource_id in resource_ids
}
+
+
+def authorised_for2(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource,
+ privileges: tuple[str, ...]
+) -> bool:
+ """
+ Check that `user` has **ALL** the specified privileges for the resource.
+ """
+ with db.cursor(conn) as cursor:
+ _query = (
+ "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+ "privileges.* "
+ "FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+ "INNER JOIN privileges "
+ "ON role_privileges.privilege_id=privileges.privilege_id "
+ "WHERE resources.resource_id=? "
+ "AND user_roles.user_id=?")
+ cursor.execute(
+ _query,
+ (str(resource.resource_id), str(user.user_id)))
+ _db_privileges = tuple(
+ db_row_to_privilege(row) for row in cursor.fetchall())
+
+ str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
+ return all((requested_privilege in str_privileges)
+ for requested_privilege in privileges)
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
new file mode 100644
index 0000000..5d2b72b
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -0,0 +1,24 @@
+"""Utilities common to more than one resource."""
+import uuid
+
+from sqlite3 import Cursor
+
+def assign_resource_owner_role(
+ cursor: Cursor,
+ resource_id: uuid.UUID,
+ user_id: uuid.UUID
+) -> dict:
+ """Assign `user` the 'Resource Owner' role for `resource`."""
+ cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
+ role = cursor.fetchone()
+ params = {
+ "user_id": str(user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ }
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES (:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/__init__.py b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
new file mode 100644
index 0000000..f401e28
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
@@ -0,0 +1 @@
+"""Initialise a genotypes resources package."""
diff --git a/gn_auth/auth/authorisation/resources/genotype.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index 206ab61..762ee7c 100644
--- a/gn_auth/auth/authorisation/resources/genotype.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -5,9 +5,8 @@ from typing import Optional, Sequence
import sqlite3
import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource
+from gn_auth.auth.authorisation.resources.data import __attach_data__
def resource_data(
@@ -28,14 +27,15 @@ def resource_data(
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Genotype data with a resource."""
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Genotype data with a resource using the GUI."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO genotype_resources VALUES"
"(:resource_id, :data_link_id)",
params)
@@ -67,3 +67,45 @@ def attach_resources_data(
f"WHERE gr.resource_id IN ({placeholders})",
tuple(str(resource.resource_id) for resource in resources))
return __attach_data__(cursor.fetchall(), resources)
+
+
+def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor,
+ resource_id: uuid.UUID,
+ group_id: uuid.UUID,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ dataset_name: str,
+ dataset_fullname: str,
+ dataset_shortname: str
+) -> dict:
+ """Link the genotype identifier data to the genotype resource."""
+ params = {
+ "resource_id": str(resource_id),
+ "group_id": str(group_id),
+ "data_link_id": str(uuid.uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "dataset_id": dataset_id,
+ "dataset_name": dataset_name,
+ "dataset_fullname": dataset_fullname,
+ "dataset_shortname": dataset_shortname
+ }
+ cursor.execute(
+ "INSERT INTO linked_genotype_data "
+ "VALUES ("
+ ":data_link_id,"
+ ":group_id,"
+ ":species_id,"
+ ":population_id,"
+ ":dataset_id,"
+ ":dataset_name,"
+ ":dataset_fullname,"
+ ":dataset_shortname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO genotype_resources VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/views.py b/gn_auth/auth/authorisation/resources/genotypes/views.py
new file mode 100644
index 0000000..2beed58
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/views.py
@@ -0,0 +1,78 @@
+"""Genotype-resources-specific views."""
+import uuid
+
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+
+from gn_auth.auth.authorisation.resources.base import ResourceCategory
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.resources.groups.models import user_group
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from gn_auth.auth.authorisation.resources.models import create_resource
+from gn_auth.auth.authorisation.resources.common import (
+ assign_resource_owner_role)
+
+
+from .models import insert_and_link_data_to_resource
+
+genobp = Blueprint("genotypes", __name__)
+
+@genobp.route("genotypes/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_geno_resource():
+ """Create a new genotype resource."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='genotype'")
+ row = cursor.fetchone()
+
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "dataset_name",
+ "dataset_fullname",
+ "dataset_shortname"
+ ).then(
+ lambda form: user_group(conn, _token.user).maybe(
+ Left("No user group found!"),
+ lambda group: Right({"formdata": form, "group": group}))
+ ).then(
+ lambda fdgrp: {
+ **fdgrp,
+ "resource": create_resource(
+ cursor,
+ f"Genotype — {fdgrp['formdata']['dataset_fullname']}",
+ ResourceCategory(uuid.UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ _token.user,
+ fdgrp["group"],
+ fdgrp["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda fdgrpres: {
+ **fdgrpres,
+ "owner_role": assign_resource_owner_role(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ _token.user.user_id)}
+ ).then(
+ lambda fdgrpres: insert_and_link_data_to_resource(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ fdgrpres["group"].group_id,
+ fdgrpres["formdata"]["species_id"],
+ fdgrpres["formdata"]["population_id"],
+ fdgrpres["formdata"]["dataset_id"],
+ fdgrpres["formdata"]["dataset_name"],
+ fdgrpres["formdata"]["dataset_fullname"],
+ fdgrpres["formdata"]["dataset_shortname"])
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/groups/data.py b/gn_auth/auth/authorisation/resources/groups/data.py
index 702955d..ad0dfba 100644
--- a/gn_auth/auth/authorisation/resources/groups/data.py
+++ b/gn_auth/auth/authorisation/resources/groups/data.py
@@ -1,7 +1,7 @@
"""Handles the resource objects' data."""
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.errors import NotFoundError
@@ -9,7 +9,7 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups import Group
def __fetch_mrna_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch mRNA Assay data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -27,7 +27,7 @@ def __fetch_mrna_data_by_ids__(
raise NotFoundError("Could not find mRNA Assay data with the given ID.")
def __fetch_geno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch genotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -45,7 +45,7 @@ def __fetch_geno_data_by_ids__(
raise NotFoundError("Could not find Genotype data with the given ID.")
def __fetch_pheno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch phenotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -67,7 +67,7 @@ def __fetch_pheno_data_by_ids__(
"Could not find Phenotype/Publish data with the given IDs.")
def __fetch_data_by_id(
- conn: gn3db.DbConnection, dataset_type: str,
+ conn: gn3db.Connection, dataset_type: str,
dataset_ids: tuple[str, ...]) -> tuple[dict, ...]:
"""Fetch data from MySQL by IDs."""
fetch_fns = {
@@ -83,7 +83,7 @@ def __fetch_data_by_id(
"group(s)."),
oauth2_scope="profile group resource")
def link_data_to_group(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
dataset_type: str, dataset_ids: tuple[str, ...], group: Group) -> tuple[
dict, ...]:
"""Link the given data to the specified group."""
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 03a93b6..2df5f04 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -5,16 +5,21 @@ from functools import reduce
from dataclasses import dataclass
from typing import Any, Sequence, Iterable, Optional
+import sqlite3
from flask import g
from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
from gn_auth.auth.errors import (
NotFoundError, AuthorisationError, InconsistencyError)
from gn_auth.auth.authorisation.roles.models import (
@@ -63,6 +68,13 @@ class MembershipError(AuthorisationError):
super().__init__(f"{type(self).__name__}: {error_description}.")
+def db_row_to_group(row: sqlite3.Row) -> Group:
+ """Convert a database row into a group."""
+ return Group(UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))
+
+
def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"""Returns all the groups that a member belongs to"""
query = (
@@ -72,7 +84,7 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"WHERE group_users.user_id=?")
with db.cursor(conn) as cursor:
cursor.execute(query, (str(user.user_id),))
- groups = tuple(Group(row[0], row[1], json.loads(row[2]))
+ groups = tuple(Group(row[0], row[1], json.loads(row[2] or "{}"))
for row in cursor.fetchall())
return groups
@@ -110,7 +122,7 @@ def create_group(
cursor, group_name, (
{"group_description": group_description}
if group_description else {}))
- group_resource = {
+ _group_resource = {
"group_id": str(new_group.group_id),
"resource_id": str(uuid4()),
"resource_name": group_name,
@@ -123,17 +135,17 @@ def create_group(
cursor.execute(
"INSERT INTO resources VALUES "
"(:resource_id, :resource_name, :resource_category_id, :public)",
- group_resource)
+ _group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
- group_resource)
+ _group_resource)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
assign_user_role_by_name(
cursor,
group_leader,
- UUID(str(group_resource["resource_id"])),
+ UUID(str(_group_resource["resource_id"])),
"group-leader")
return new_group
@@ -489,3 +501,44 @@ def add_resources_to_group(conn: db.DbConnection,
"group_id": str(group.group_id),
"resource_id": str(rsc.resource_id)
} for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+ """Return a group where at least one system admin is a member."""
+ query = (
+ "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+ "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+ "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+ "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+ "WHERE role_name='system-administrator'")
+ with db.cursor(conn) as cursor:
+ cursor.execute(query)
+ return monad_from_none_or_value(
+ Left("There is no group of which the system admininstrator is a "
+ "member."),
+ lambda row: Right(Group(
+ UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))),
+ cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT group_resources.group_id, resource_categories.*, "
+ "resources.resource_id, resources.resource_name, resources.public "
+ "FROM group_resources INNER JOIN resources "
+ "ON group_resources.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_resources.group_id=? "
+ "AND resource_categories.resource_category_key='group'",
+ (str(group_id),))
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a resource for group with ID "
+ f"{group_id}")
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index ef6bb0d..746e23c 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -3,23 +3,19 @@ The views/routes for the `gn3.auth.authorisation.resources.groups` package.
"""
import uuid
import datetime
-import warnings
-from typing import Iterable
from functools import partial
from dataclasses import asdict
from MySQLdb.cursors import DictCursor
-from flask import request, jsonify, Response, Blueprint, current_app
+from flask import jsonify, Response, Blueprint, current_app
+from gn_libs import mysqldb as gn3db
+
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authorisation.roles.models import Role
-from gn_auth.auth.authorisation.roles.models import user_roles
-
-from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.privileges import Privilege, privileges_by_ids
+from gn_auth.auth.authorisation.privileges import privileges_by_ids
from gn_auth.auth.errors import InvalidData, NotFoundError, AuthorisationError
from gn_auth.auth.authentication.users import User
@@ -31,7 +27,7 @@ from .models import (
join_requests, group_role_by_id, GroupCreationError,
accept_reject_join_request, group_users as _group_users,
create_group as _create_group, add_privilege_to_group_role,
- delete_privilege_from_group_role, create_group_role as _create_group_role)
+ delete_privilege_from_group_role)
groups = Blueprint("groups", __name__)
@@ -50,15 +46,17 @@ def list_groups():
def create_group():
"""Create a new group."""
with require_oauth.acquire("profile group") as the_token:
- group_name=request.form.get("group_name", "").strip()
+ group_name=request_json().get("group_name", "").strip()
if not bool(group_name):
- raise GroupCreationError("Could not create the group.")
+ raise GroupCreationError(
+ "Could not create the group. Invalid Group name provided was "
+ f"`{group_name}`")
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
user = the_token.user
new_group = _create_group(
- conn, group_name, user, request.form.get("group_description"))
+ conn, group_name, user, request_json().get("group_description"))
return jsonify({
**asdict(new_group), "group_leader": asdict(user)
})
@@ -107,7 +105,7 @@ def request_to_join(group_id: uuid.UUID) -> Response:
}
with require_oauth.acquire("profile group") as the_token:
- form = request.form
+ form = request_json()
results = with_db_connection(partial(
__request__, user=the_token.user, group_id=group_id, message=form.get(
"message", "I hereby request that you add me to your group.")))
@@ -126,7 +124,7 @@ def list_join_requests() -> Response:
def accept_join_requests() -> Response:
"""Accept a join request."""
with require_oauth.acquire("profile group") as the_token:
- form = request.form
+ form = request_json()
request_id = uuid.UUID(form.get("request_id"))
return jsonify(with_db_connection(partial(
accept_reject_join_request, request_id=request_id,
@@ -137,7 +135,7 @@ def accept_join_requests() -> Response:
def reject_join_requests() -> Response:
"""Reject a join request."""
with require_oauth.acquire("profile group") as the_token:
- form = request.form
+ form = request_json()
request_id = uuid.UUID(form.get("request_id"))
return jsonify(with_db_connection(partial(
accept_reject_join_request, request_id=request_id,
@@ -171,7 +169,7 @@ def unlinked_genotype_data(
return tuple(dict(row) for row in cursor.fetchall())
def unlinked_phenotype_data(
- authconn: db.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: db.DbConnection, gn3conn: gn3db.Connection,
group: Group) -> tuple[dict, ...]:
"""
Retrieve all phenotype data linked to a group but not linked to any
@@ -237,7 +235,7 @@ def unlinked_data(resource_type: str) -> Response:
if resource_type in ("system", "group"):
return jsonify(tuple())
- if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+ if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
raise AuthorisationError(f"Invalid resource type {resource_type}")
with require_oauth.acquire("profile group resource") as the_token:
@@ -255,7 +253,8 @@ def unlinked_data(resource_type: str) -> Response:
"genotype": unlinked_genotype_data,
"phenotype": lambda conn, grp: partial(
unlinked_phenotype_data, gn3conn=gn3conn)(
- authconn=conn, group=grp)
+ authconn=conn, group=grp),
+ "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
}
return jsonify(tuple(
dict(row) for row in unlinked_fns[resource_type](
@@ -268,9 +267,9 @@ def unlinked_data(resource_type: str) -> Response:
def link_data() -> Response:
"""Link selected data to specified group."""
with require_oauth.acquire("profile group resource") as _the_token:
- form = request.form
+ form = request_json()
group_id = uuid.UUID(form["group_id"])
- dataset_ids = form.getlist("dataset_ids")
+ dataset_ids = form.get("dataset_ids")
dataset_type = form.get("dataset_type")
if dataset_type not in ("mrna", "genotype", "phenotype"):
raise InvalidData("Unexpected dataset type requested!")
@@ -282,70 +281,6 @@ def link_data() -> Response:
return jsonify(with_db_connection(__link__))
-@groups.route("/privileges", methods=["GET"])
-@require_oauth("profile group")
-def group_privileges():
- """Return a list of all available group roles."""
- with require_oauth.acquire("profile group role") as the_token:
- def __list_privileges__(conn: db.DbConnection) -> Iterable[Privilege]:
- ## TODO: Check that user has appropriate privileges
- this_user_roles = user_roles(conn, the_token.user)
- with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM privileges "
- "WHERE privilege_id LIKE 'group:%'")
- group_level_roles = tuple(
- Privilege(row["privilege_id"], row["privilege_description"])
- for row in cursor.fetchall())
-
- ## the `user_roles(...)` function changed thus this entire function
- ## needs to change or be obsoleted -- also remove the ignore below
- return tuple(
- privilege for arole in this_user_roles["roles"]
- for privilege in arole.privileges) + group_level_roles #type: ignore[attr-defined]
- warnings.warn(
- (f"The `{__name__}.group_privileges` function is broken and will "
- "be deleted."),
- PendingDeprecationWarning)
- return jsonify(tuple(
- asdict(priv) for priv in with_db_connection(__list_privileges__)))
-
-
-
-@groups.route("/role/create", methods=["POST"])
-@require_oauth("profile group")
-def create_group_role():
- """Create a new group role."""
- with require_oauth.acquire("profile group role") as the_token:
- ## TODO: Check that user has appropriate privileges
- @authorised_p(("group:role:create-role",),
- "You do not have the privilege to create new roles",
- oauth2_scope="profile group role")
- def __create__(conn: db.DbConnection) -> GroupRole:
- ## TODO: Check user cannot assign any privilege they don't have.
- form = request.form
- role_name = form.get("role_name", "").strip()
- privileges_ids = form.getlist("privileges[]")
- if len(role_name) == 0:
- raise InvalidData("Role name not provided!")
- if len(privileges_ids) == 0:
- raise InvalidData(
- "At least one privilege needs to be provided.")
-
- group = user_group(conn, the_token.user).maybe(# type: ignore[misc]
- DUMMY_GROUP, lambda grp: grp)
-
- if group == DUMMY_GROUP:
- raise AuthorisationError(
- "A user without a group cannot create a new role.")
- privileges = privileges_by_ids(conn, tuple(privileges_ids))
- if len(privileges_ids) != len(privileges):
- raise InvalidData(
- f"{len(privileges_ids) - len(privileges)} of the selected "
- "privileges were not found in the database.")
-
- return _create_group_role(conn, group, role_name, privileges)
-
- return jsonify(with_db_connection(__create__))
@groups.route("/role/<uuid:group_role_id>", methods=["GET"])
@require_oauth("profile group")
@@ -374,7 +309,7 @@ def __add_remove_priv_to_from_role__(conn: db.DbConnection,
raise AuthorisationError(
"You need to be a member of a group to edit roles.")
try:
- privilege_id = request.form.get("privilege_id", "")
+ privilege_id = request_json().get("privilege_id", "")
assert bool(privilege_id), "Privilege to add must be provided."
privileges = privileges_by_ids(conn, (privilege_id,))
if len(privileges) == 0:
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
new file mode 100644
index 0000000..64d41e3
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -0,0 +1,96 @@
+"""Functions to handle the low-level details regarding populations auth."""
+from uuid import UUID, uuid4
+
+import sqlite3
+
+from gn_auth.auth.errors import NotFoundError
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.models import (
+ create_resource as _create_resource)
+
+def create_resource(
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
+ """Convenience function to create a resource of type 'inbredset-group'."""
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='inbredset-group'")
+ category = cursor.fetchone()
+ if category:
+ return _create_resource(cursor,
+ resource_name,
+ ResourceCategory(
+ resource_category_id=UUID(
+ category["resource_category_id"]),
+ resource_category_key="inbredset-group",
+ resource_category_description=category[
+ "resource_category_description"]),
+ user,
+ group,
+ public)
+ raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+
+
+def assign_inbredset_group_owner_role(
+ cursor: sqlite3.Cursor,
+ resource: Resource,
+ user: User
+) -> Resource:
+ """
+ Assign `user` as `InbredSet Group Owner` is resource category is
+ 'inbredset-group'.
+ """
+ if resource.resource_category.resource_category_key == "inbredset-group":
+ cursor.execute(
+ "SELECT * FROM roles WHERE role_name='inbredset-group-owner'")
+ role = cursor.fetchone()
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES(:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ {
+ "user_id": str(user.user_id),
+ "role_id": str(role["role_id"]),
+ "resource_id": str(resource.resource_id)
+ })
+
+ return resource
+
+
+def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_id: UUID,
+ species_id: int,
+ population_id: int,
+ population_name: str,
+ population_fullname: str
+) -> dict:
+ """Link a species population to a resource for auth purposes."""
+ params = {
+ "resource_id": str(resource_id),
+ "data_link_id": str(uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "population_name": population_name,
+ "population_fullname": population_fullname
+ }
+ cursor.execute(
+ "INSERT INTO linked_inbredset_groups "
+ "VALUES("
+ " :data_link_id,"
+ " :species_id,"
+ " :population_id,"
+ " :population_name,"
+ " :population_fullname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO inbredset_group_resources "
+ "VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index 444c442..40dd38d 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,12 +1,22 @@
"""Views for InbredSet resources."""
-from flask import jsonify, Response, Blueprint
+from pymonad.either import Left, Right, Either
+from flask import jsonify, Response, Blueprint, current_app as app
+
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group
+
+from .models import (create_resource,
+ link_data_to_resource,
+ assign_inbredset_group_owner_role)
-iset = Blueprint("inbredset", __name__)
+popbp = Blueprint("populations", __name__)
-@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>")
+@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>",
+ methods=["GET"])
def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
"""Retrieve the resource ID for resource attached to the inbredset."""
def __res_by_iset_id__(conn):
@@ -34,3 +44,76 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
resp.status_code = 404
return resp
+
+
+@popbp.route("/populations/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_population_resource():
+ """Create a resource of type 'inbredset-group'."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+
+ def __check_form__(form, usergroup) -> Either:
+ """Check form for errors."""
+ errors: tuple[str, ...] = tuple()
+
+ species_id = form.get("species_id")
+ if not bool(species_id):
+ errors = errors + ("Missing `species_id` value.",)
+
+ population_id = form.get("population_id")
+ if not bool(population_id):
+ errors = errors + ("Missing `population_id` value.",)
+
+ population_name = form.get("population_name")
+ if not bool(population_name):
+ errors = errors + ("Missing `population_name` value.",)
+
+ population_fullname = form.get("population_fullname")
+ if not bool(population_fullname):
+ errors = errors + ("Missing `population_fullname` value.",)
+
+ if bool(errors):
+ error_messages = "\n\t - ".join(errors)
+ return Left({
+ "error": "Invalid Request Data!",
+ "error_description": error_messages
+ })
+
+ return Right({"formdata": form, "group": usergroup})
+
+ def __default_group_if_none__(group) -> Either:
+ if group.is_nothing():
+ return admin_group(conn)
+ return Right(group.value)
+
+ return __default_group_if_none__(
+ user_group(conn, _token.user)
+ ).then(
+ lambda group: __check_form__(request_json(), group)
+ ).then(
+ lambda formdata: {
+ **formdata,
+ "resource": create_resource(
+ cursor,
+ f"Population — {formdata['formdata']['population_name']}",
+ _token.user,
+ formdata["group"],
+ formdata["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda resource: {
+ **resource,
+ "resource": assign_inbredset_group_owner_role(
+ cursor, resource["resource"], _token.user)}
+ ).then(
+ lambda resource: link_data_to_resource(
+ cursor,
+ resource["resource"].resource_id,
+ resource["formdata"]["species_id"],
+ resource["formdata"]["population_id"],
+ resource["formdata"]["population_name"],
+ resource["formdata"]["population_fullname"])
+ ).either(
+ lambda error: (jsonify(error), 400),
+ jsonify)
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index 95a7f1c..e538a87 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -4,6 +4,8 @@ from uuid import UUID, uuid4
from functools import reduce, partial
from typing import Dict, Sequence, Optional
+import sqlite3
+
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
from gn_auth.auth.db.sqlite3 import with_db_connection
@@ -14,79 +16,59 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from .checks import authorised_for
-from .base import Resource, ResourceCategory
-from .groups.models import Group, GroupRole, user_group, is_group_leader
+from .base import Resource, ResourceCategory, resource_from_dbrow
+from .common import assign_resource_owner_role
+from .groups.models import Group, is_group_leader
from .mrna import (
resource_data as mrna_resource_data,
attach_resources_data as mrna_attach_resources_data,
link_data_to_resource as mrna_link_data_to_resource,
unlink_data_from_resource as mrna_unlink_data_from_resource)
-from .genotype import (
+from .genotypes.models import (
resource_data as genotype_resource_data,
attach_resources_data as genotype_attach_resources_data,
link_data_to_resource as genotype_link_data_to_resource,
unlink_data_from_resource as genotype_unlink_data_from_resource)
-from .phenotype import (
+from .phenotypes.models import (
resource_data as phenotype_resource_data,
attach_resources_data as phenotype_attach_resources_data,
link_data_to_resource as phenotype_link_data_to_resource,
unlink_data_from_resource as phenotype_unlink_data_from_resource)
-from .errors import MissingGroupError
-
-def __assign_resource_owner_role__(cursor, resource, user):
- """Assign `user` the 'Resource Owner' role for `resource`."""
- cursor.execute(
- "SELECT rr.* FROM resource_roles AS rr INNER JOIN roles AS r "
- "ON rr.role_id=r.role_id WHERE r.role_name='resource-owner' "
- "AND rr.resource_id=?",
- (str(resource.resource_id),))
- role = cursor.fetchone()
- if not role:
- cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
- role = cursor.fetchone()
- cursor.execute(
- "INSERT INTO resource_roles(resource_id, role_created_by, role_id) "
- "VALUES (:resource_id, :user_id, :role_id)",
- {"resource_id": str(resource.resource_id),
- "user_id": str(user.user_id),
- "role_id": role["role_id"]})
-
- cursor.execute(
- "INSERT INTO user_roles "
- "VALUES (:user_id, :role_id, :resource_id) "
- "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
- {
- "user_id": str(user.user_id),
- "role_id": role["role_id"],
- "resource_id": str(resource.resource_id)
- })
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
-def create_resource(
- conn: db.DbConnection, resource_name: str,
- resource_category: ResourceCategory, user: User,
- public: bool) -> Resource:
+def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ resource_category: ResourceCategory,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
"""Create a resource item."""
- with db.cursor(conn) as cursor:
- group = user_group(conn, user).maybe(
- False, lambda grp: grp)# type: ignore[misc, arg-type]
- if not group:
- raise MissingGroupError(# Not all resources require an owner group
- "User with no group cannot create a resource.")
- resource = Resource(uuid4(), resource_name, resource_category, public)
- cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
- (str(resource.resource_id),
- resource_name,
- str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
- cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
- "VALUES (?, ?)",
- (str(group.group_id), str(resource.resource_id)))
- __assign_resource_owner_role__(cursor, resource, user)
+ resource = Resource(uuid4(), resource_name, resource_category, public)
+ cursor.execute(
+ "INSERT INTO resources VALUES (?, ?, ?, ?)",
+ (str(resource.resource_id),
+ resource_name,
+ str(resource.resource_category.resource_category_id),
+ 1 if resource.public else 0))
+ # TODO: @fredmanglis,@rookie101
+ # 1. Move the actions below into a (the?) hooks system
+ # 2. Do more checks: A resource can have varying hooks depending on type
+ # e.g. if mRNA, pheno or geno resource, assign:
+ # - "resource-owner"
+ # if inbredset-group, assign:
+ # - "resource-owner",
+ # - "inbredset-group-owner" etc.
+ # if resource is of type "group", assign:
+ # - group-leader
+ cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
+ "VALUES (?, ?)",
+ (str(group.group_id), str(resource.resource_id)))
+ assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
return resource
@@ -149,32 +131,21 @@ def group_leader_resources(
def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
"""List the resources available to the user"""
- categories = { # Repeated in `public_resources` function
- cat.resource_category_id: cat for cat in resource_categories(conn)
- }
with db.cursor(conn) as cursor:
- def __all_resources__(group) -> Sequence[Resource]:
- gl_resources = group_leader_resources(conn, user, group, categories)
+ cursor.execute(
+ ("SELECT DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, rc.resource_category_key, "
+ "rc.resource_category_description "
+ "FROM user_roles AS ur "
+ "INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE ur.user_id=?"),
+ (str(user.user_id),))
+ rows = cursor.fetchall() or []
+
+ return tuple(resource_from_dbrow(row) for row in rows)
- cursor.execute(
- ("SELECT resources.* FROM user_roles LEFT JOIN resources "
- "ON user_roles.resource_id=resources.resource_id "
- "WHERE user_roles.user_id=?"),
- (str(user.user_id),))
- rows = cursor.fetchall()
- private_res = tuple(
- Resource(UUID(row[0]), row[1], categories[UUID(row[2])],
- bool(row[3]))
- for row in rows)
- return tuple({
- res.resource_id: res
- for res in
- (private_res + gl_resources + public_resources(conn))# type: ignore[operator]
- }.values())
-
- # Fix the typing here
- return user_group(conn, user).map(__all_resources__).maybe(# type: ignore[arg-type,misc]
- public_resources(conn), lambda res: res)# type: ignore[arg-type,return-value]
def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]:
"""
@@ -236,8 +207,12 @@ def resource_by_id(
raise NotFoundError(f"Could not find a resource with id '{resource_id}'")
def link_data_to_resource(
- conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str,
- data_link_id: UUID) -> dict:
+ conn: db.DbConnection,
+ user: User,
+ resource_id: UUID,
+ dataset_type: str,
+ data_link_ids: tuple[UUID, ...]
+) -> tuple[dict, ...]:
"""Link data to resource."""
if not authorised_for(
conn, user, ("group:resource:edit-resource",),
@@ -252,7 +227,7 @@ def link_data_to_resource(
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
"phenotype": phenotype_link_data_to_resource,
- }[dataset_type.lower()](conn, resource, data_link_id)
+ }[dataset_type.lower()](conn, resource, data_link_ids)
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
@@ -305,13 +280,13 @@ def attach_resources_data(
for category, rscs in organised.items())
for resource in categories)
-@authorised_p(
- ("group:user:assign-role",),
- "You cannot assign roles to users for this group.",
- oauth2_scope="profile group role resource")
+
def assign_resource_user(
- conn: db.DbConnection, resource: Resource, user: User,
- role: GroupRole) -> dict:
+ conn: db.DbConnection,
+ resource: Resource,
+ user: User,
+ role: Role
+) -> dict:
"""Assign `role` to `user` for the specific `resource`."""
with db.cursor(conn) as cursor:
cursor.execute(
@@ -319,39 +294,36 @@ def assign_resource_user(
"VALUES (?, ?, ?) "
"ON CONFLICT (user_id, role_id, resource_id) "
"DO NOTHING",
- (str(user.user_id), str(role.role.role_id),
- str(resource.resource_id)))
+ (str(user.user_id), str(role.role_id), str(resource.resource_id)))
return {
"resource": asdict(resource),
"user": asdict(user),
"role": asdict(role),
"description": (
f"The user '{user.name}'({user.email}) was assigned the "
- f"'{role.role.role_name}' role on resource with ID "
+ f"'{role.role_name}' role on resource with ID "
f"'{resource.resource_id}'.")}
-@authorised_p(
- ("group:user:assign-role",),
- "You cannot assign roles to users for this group.",
- oauth2_scope="profile group role resource")
+
def unassign_resource_user(
- conn: db.DbConnection, resource: Resource, user: User,
- role: GroupRole) -> dict:
+ conn: db.DbConnection,
+ resource: Resource,
+ user: User,
+ role: Role
+) -> dict:
"""Assign `role` to `user` for the specific `resource`."""
with db.cursor(conn) as cursor:
cursor.execute(
"DELETE FROM user_roles "
"WHERE user_id=? AND role_id=? AND resource_id=?",
- (str(user.user_id),
- str(role.role.role_id),
- str(resource.resource_id)))
+ (str(user.user_id), str(role.role_id), str(resource.resource_id)))
return {
"resource": asdict(resource),
"user": asdict(user),
"role": asdict(role),
"description": (
f"The user '{user.name}'({user.email}) had the "
- f"'{role.role.role_name}' role on resource with ID "
+ f"'{role.role_name}' role on resource with ID "
f"'{resource.resource_id}' taken away.")}
def save_resource(
diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py
index 7fce227..66f8824 100644
--- a/gn_auth/auth/authorisation/resources/mrna.py
+++ b/gn_auth/auth/authorisation/resources/mrna.py
@@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor,
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link mRNA Assay data with a resource."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO mrna_resources VALUES"
"(:resource_id, :data_link_id)",
params)
diff --git a/gn_auth/auth/authorisation/resources/phenotype.py b/gn_auth/auth/authorisation/resources/phenotype.py
deleted file mode 100644
index 7005db3..0000000
--- a/gn_auth/auth/authorisation/resources/phenotype.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Phenotype data resources functions and utilities."""
-import uuid
-from typing import Optional, Sequence
-
-import sqlite3
-
-import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
-
-def resource_data(
- cursor: db.DbCursor,
- resource_id: uuid.UUID,
- offset: int = 0,
- limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
- """Fetch data linked to a Phenotype resource"""
- cursor.execute(
- ("SELECT * FROM phenotype_resources AS pr "
- "INNER JOIN linked_phenotype_data AS lpd "
- "ON pr.data_link_id=lpd.data_link_id "
- "WHERE pr.resource_id=?") + (
- f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
- (str(resource_id),))
- return cursor.fetchall()
-
-def link_data_to_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Phenotype data with a resource."""
- with db.cursor(conn) as cursor:
- params = {
- "resource_id": str(resource.resource_id),
- "data_link_id": str(data_link_id)
- }
- cursor.execute(
- "INSERT INTO phenotype_resources VALUES"
- "(:resource_id, :data_link_id)",
- params)
- return params
-
-def unlink_data_from_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Unlink data from Phenotype resources"""
- with db.cursor(conn) as cursor:
- cursor.execute("DELETE FROM phenotype_resources "
- "WHERE resource_id=? AND data_link_id=?",
- (str(resource.resource_id), str(data_link_id)))
- return {
- "resource_id": str(resource.resource_id),
- "dataset_type": resource.resource_category.resource_category_key,
- "data_link_id": str(data_link_id)
- }
-
-def attach_resources_data(
- cursor, resources: Sequence[Resource]) -> Sequence[Resource]:
- """Attach linked data to Phenotype resources"""
- placeholders = ", ".join(["?"] * len(resources))
- cursor.execute(
- "SELECT * FROM phenotype_resources AS pr "
- "INNER JOIN linked_phenotype_data AS lpd "
- "ON pr.data_link_id=lpd.data_link_id "
- f"WHERE pr.resource_id IN ({placeholders})",
- tuple(str(resource.resource_id) for resource in resources))
- return __attach_data__(cursor.fetchall(), resources)
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/__init__.py b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
new file mode 100644
index 0000000..0d4dbfa
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
@@ -0,0 +1 @@
+"""The phenotypes package."""
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py
new file mode 100644
index 0000000..0ef91ab
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -0,0 +1,143 @@
+"""Phenotype data resources functions and utilities."""
+import uuid
+from functools import reduce
+from typing import Optional, Sequence
+
+import sqlite3
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.tools import monad_from_none_or_value
+
+import gn_auth.auth.db.sqlite3 as db
+from gn_auth.auth.authorisation.resources.data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource, resource_from_dbrow
+
+def resource_data(
+ cursor: db.DbCursor,
+ resource_id: uuid.UUID,
+ offset: int = 0,
+ limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
+ """Fetch data linked to a Phenotype resource"""
+ cursor.execute(
+ ("SELECT * FROM phenotype_resources AS pr "
+ "INNER JOIN linked_phenotype_data AS lpd "
+ "ON pr.data_link_id=lpd.data_link_id "
+ "WHERE pr.resource_id=?") + (
+ f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
+ (str(resource_id),))
+ return cursor.fetchall()
+
+def link_data_to_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Phenotype data with a resource."""
+ with db.cursor(conn) as cursor:
+ params = tuple({
+ "resource_id": str(resource.resource_id),
+ "data_link_id": str(data_link_id)
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
+ "INSERT INTO phenotype_resources VALUES"
+ "(:resource_id, :data_link_id)",
+ params)
+ return params
+
+def unlink_data_from_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_id: uuid.UUID) -> dict:
+ """Unlink data from Phenotype resources"""
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM phenotype_resources "
+ "WHERE resource_id=? AND data_link_id=?",
+ (str(resource.resource_id), str(data_link_id)))
+ return {
+ "resource_id": str(resource.resource_id),
+ "dataset_type": resource.resource_category.resource_category_key,
+ "data_link_id": str(data_link_id)
+ }
+
+def attach_resources_data(
+ cursor, resources: Sequence[Resource]) -> Sequence[Resource]:
+ """Attach linked data to Phenotype resources"""
+ placeholders = ", ".join(["?"] * len(resources))
+ cursor.execute(
+ "SELECT * FROM phenotype_resources AS pr "
+ "INNER JOIN linked_phenotype_data AS lpd "
+ "ON pr.data_link_id=lpd.data_link_id "
+ f"WHERE pr.resource_id IN ({placeholders})",
+ tuple(str(resource.resource_id) for resource in resources))
+ return __attach_data__(cursor.fetchall(), resources)
+
+
+def individual_linked_resource(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_id: str) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT "
+ "rsc.*, rc.*, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS rsc ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId, lpd.PublishXRefId) = "
+ "(?, ?, ?, ?)",
+ (species_id, population_id, dataset_id, xref_id))
+ return monad_from_none_or_value(
+ Nothing, Just, cursor.fetchone()).then(resource_from_dbrow)
+
+
+def all_linked_resources(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT rsc.*, rc.resource_category_key, "
+ "rc.resource_category_description, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id INNER JOIN resources AS rsc "
+ "ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId) = (?, ?, ?)",
+ (species_id, population_id, dataset_id))
+
+ _rscdatakeys = (
+ "species_id", "population_id", "xref_id", "dataset_name",
+ "dataset_fullname", "dataset_shortname")
+ def __organise__(resources, row):
+ _rscid = uuid.UUID(row["resource_id"])
+ _resource = resources.get(_rscid, resource_from_dbrow(row))
+ return {
+ **resources,
+ _rscid: Resource(
+ _resource.resource_id,
+ _resource.resource_name,
+ _resource.resource_category,
+ _resource.public,
+ _resource.resource_data + (
+ {key: row[key] for key in _rscdatakeys},))
+ }
+ results: dict[uuid.UUID, Resource] = reduce(
+ __organise__, cursor.fetchall(), {})
+ if len(results) == 0:
+ return Nothing
+ return Just(tuple(results.values()))
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/views.py b/gn_auth/auth/authorisation/resources/phenotypes/views.py
new file mode 100644
index 0000000..c0a5e81
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py
@@ -0,0 +1,77 @@
+"""Views for the phenotype resources."""
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.roles.models import user_roles_on_resource
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from .models import all_linked_resources, individual_linked_resource
+
+phenobp = Blueprint("phenotypes", __name__)
+
+@phenobp.route("/phenotypes/individual/linked-resource", methods=["POST"])
+@require_oauth("profile group resource")
+def get_individual_linked_resource():
+ """Get the linked resource for a particular phenotype within the dataset.
+
+ Phenotypes are a tad tricky. Each phenotype could technically be a resource
+ on its own, and thus a user could have access to only a subset of phenotypes
+ within the entire dataset."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "xref_id"
+ ).then(
+ lambda formdata: individual_linked_resource(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"]),
+ formdata["xref_id"]
+ ).maybe(Left("No linked resource!"),
+ lambda lrsc: Right({
+ "formdata": formdata,
+ "resource": lrsc
+ }))
+ ).then(
+ lambda fdlrsc: {
+ **fdlrsc,
+ "roles": user_roles_on_resource(
+ conn, _token.user.user_id, fdlrsc["resource"].resource_id)
+ }
+ ).either(lambda error: (jsonify(error), 400),
+ lambda res: jsonify({
+ key: value for key, value in res.items()
+ if key != "formdata"
+ }))
+
+
+@phenobp.route("/phenotypes/linked-resources", methods=["POST"])
+@require_oauth("profile group resource")
+def get_all_linked_resources():
+ """Get all the linked resources for all phenotypes within a dataset.
+
+ See `get_individual_linked_resource(…)` documentation."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id"
+ ).then(
+ lambda formdata: all_linked_resources(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"])).maybe(
+ Left("No linked resource!"), Right)
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/request_utils.py b/gn_auth/auth/authorisation/resources/request_utils.py
new file mode 100644
index 0000000..ade779e
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/request_utils.py
@@ -0,0 +1,20 @@
+"""Some common utils for requests to the resources endpoints."""
+from functools import reduce
+
+from pymonad.either import Left, Right, Either
+
+def check_form(form, *fields) -> Either:
+ """Check form for errors"""
+ def __check_field__(errors, field):
+ if not bool(form.get(field)):
+ return errors + (f"Missing `{field}` value.",)
+ return errors
+
+ errors: tuple[str, ...] = reduce(__check_field__, fields, tuple())
+ if len(errors) > 0:
+ return Left({
+ "error": "Invalid request data!",
+ "error_description": "\n\t - ".join(errors)
+ })
+
+ return Right(form)
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 7c176aa..303b0ac 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -4,11 +4,15 @@ from functools import reduce
from typing import Sequence
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
def __organise_privileges__(acc, row):
role_id = UUID(row["role_id"])
@@ -24,6 +28,7 @@ def __organise_privileges__(acc, row):
(Privilege(row["privilege_id"], row["privilege_description"]),)))
}
+
def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
"""
Retrieve all roles assigned to the `user` that act on `system` resources.
@@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple(reduce(
__organise_privileges__, cursor.fetchall(), {}).values())
return tuple()
+
+
+def system_resource(conn: db.DbConnection) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT resource_categories.*, resources.resource_id, "
+ "resources.resource_name, resources.public "
+ "FROM resource_categories INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_categories.resource_category_key='system'")
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a system resource!")
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 50f0d8e..0a68927 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -8,36 +8,54 @@ import time
from dataclasses import asdict
from functools import reduce
-from authlib.integrations.flask_oauth2.errors import _HTTPException
+from werkzeug.exceptions import BadRequest
from authlib.jose import jwt
+from authlib.integrations.flask_oauth2.errors import _HTTPException
from flask import (make_response, request, jsonify, Response,
Blueprint, current_app as app)
+from gn_auth.auth.requests import request_json
+
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.jwks import newest_jwk, jwks_directory
from gn_auth.auth.authorisation.roles import Role
-from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.errors import InvalidData, InconsistencyError, AuthorisationError
+from gn_auth.auth.authorisation.roles.models import (
+ create_role,
+ user_resource_roles as _user_resource_roles)
+from gn_auth.auth.errors import (
+ InvalidData,
+ InconsistencyError,
+ AuthorisationError)
+from gn_auth.auth.authorisation.privileges import (
+ privilege_by_id,
+ privileges_by_ids)
from gn_auth.auth.authorisation.roles.models import (
role_by_id,
db_rows_to_roles,
- check_user_editable,
delete_privilege_from_resource_role)
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
from .checks import authorised_for
+from .inbredset.views import popbp
+from .genotypes.views import genobp
+from .phenotypes.views import phenobp
+from .errors import MissingGroupError
+from .groups.models import Group, user_group
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
get_resource_id)
-from .groups.models import Group, resource_owner, group_role_by_id
resources = Blueprint("resources", __name__)
+resources.register_blueprint(popbp, url_prefix="/")
+resources.register_blueprint(genobp, url_prefix="/")
+resources.register_blueprint(phenobp, url_prefix="/")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -53,17 +71,24 @@ def list_resource_categories() -> Response:
def create_resource() -> Response:
"""Create a new resource"""
with require_oauth.acquire("profile group resource") as the_token:
- form = request.form
+ form = request_json()
resource_name = form.get("resource_name")
resource_category_id = UUID(form.get("resource_category"))
db_uri = app.config["AUTH_DB"]
- with db.connection(db_uri) as conn:
+ with (db.connection(db_uri) as conn,
+ db.cursor(conn) as cursor):
try:
+ group = user_group(conn, the_token.user).maybe(
+ False, lambda grp: grp)# type: ignore[misc, arg-type]
+ if not group:
+ raise MissingGroupError(# Not all resources require an owner group
+ "User with no group cannot create a resource.")
resource = _create_resource(
- conn,
+ cursor,
resource_name,
resource_category_by_id(conn, resource_category_id),
the_token.user,
+ group,
(form.get("public") == "on"))
return jsonify(asdict(resource))
except sqlite3.IntegrityError as sql3ie:
@@ -112,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response:
with require_oauth.acquire("profile group resource") as the_token:
db_uri = app.config["AUTH_DB"]
count_per_page = __safe_get_requests_count__("count_per_page")
- offset = (__safe_get_requests_page__("page") - 1)
+ offset = __safe_get_requests_page__("page") - 1
with db.connection(db_uri) as conn:
resource = resource_by_id(conn, the_token.user, resource_id)
return jsonify(resource_data(
@@ -126,9 +151,9 @@ def view_resource_data(resource_id: UUID) -> Response:
def link_data():
"""Link group data to a specific resource."""
try:
- form = request.form
+ form = request_json()
assert "resource_id" in form, "Resource ID not provided."
- assert "data_link_id" in form, "Data Link ID not provided."
+ assert "data_link_ids" in form, "Data Link IDs not provided."
assert "dataset_type" in form, "Dataset type not specified"
assert form["dataset_type"].lower() in (
"mrna", "genotype", "phenotype"), "Invalid dataset type provided."
@@ -136,8 +161,11 @@ def link_data():
with require_oauth.acquire("profile group resource") as the_token:
def __link__(conn: db.DbConnection):
return link_data_to_resource(
- conn, the_token.user, UUID(form["resource_id"]),
- form["dataset_type"], UUID(form["data_link_id"]))
+ conn,
+ the_token.user,
+ UUID(form["resource_id"]),
+ form["dataset_type"],
+ tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"]))
return jsonify(with_db_connection(__link__))
except AssertionError as aserr:
@@ -150,7 +178,7 @@ def link_data():
def unlink_data():
"""Unlink data bound to a specific resource."""
try:
- form = request.form
+ form = request_json()
assert "resource_id" in form, "Resource ID not provided."
assert "data_link_id" in form, "Data Link ID not provided."
@@ -179,7 +207,7 @@ def resource_users(resource_id: UUID):
the_token.user,
("group:resource:view-resource",),
(resource_id,))
- systemlevelauth = __pk__authorised_for(
+ systemlevelauth = authorised_for(
conn,
the_token.user,
("system:user:list",),
@@ -237,22 +265,25 @@ def resource_users(resource_id: UUID):
@require_oauth("profile group resource role")
def assign_role_to_user(resource_id: UUID) -> Response:
"""Assign a role on the specified resource to a user."""
- with require_oauth.acquire("profile group resource role") as the_token:
+ with require_oauth.acquire("profile group resource role") as _token:
try:
- form = request.form
- group_role_id = form.get("group_role_id", "")
+ form = request_json()
+ role_id = form.get("role_id", "")
user_email = form.get("user_email", "")
- assert bool(group_role_id), "The role must be provided."
+ assert bool(role_id), "The role must be provided."
assert bool(user_email), "The user email must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- resource = resource_by_id(conn, the_token.user, resource_id)
+ authorised_for(
+ conn,
+ _token.user,
+ ("resource:role:assign-role",),
+ (resource_id,))
+ resource = resource_by_id(conn, _token.user, resource_id)
user = user_by_email(conn, user_email)
return assign_resource_user(
conn, resource, user,
- group_role_by_id(conn,
- resource_owner(conn, resource),
- UUID(group_role_id)))
+ role_by_id(conn, UUID(role_id)))# type: ignore[arg-type]
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
@@ -262,21 +293,24 @@ def assign_role_to_user(resource_id: UUID) -> Response:
@require_oauth("profile group resource role")
def unassign_role_to_user(resource_id: UUID) -> Response:
"""Unassign a role on the specified resource from a user."""
- with require_oauth.acquire("profile group resource role") as the_token:
+ with require_oauth.acquire("profile group resource role") as _token:
try:
- form = request.form
- group_role_id = form.get("group_role_id", "")
+ form = request_json()
+ role_id = form.get("role_id", "")
user_id = form.get("user_id", "")
- assert bool(group_role_id), "The role must be provided."
+ assert bool(role_id), "The role must be provided."
assert bool(user_id), "The user id must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- resource = resource_by_id(conn, the_token.user, resource_id)
+ authorised_for(
+ conn,
+ _token.user,
+ ("resource:role:assign-role",),
+ (resource_id,))
+ resource = resource_by_id(conn, _token.user, resource_id)
return unassign_resource_user(
conn, resource, user_by_id(conn, UUID(user_id)),
- group_role_by_id(conn,
- resource_owner(conn, resource),
- UUID(group_role_id)))
+ role_by_id(conn, UUID(role_id)))# type: ignore[arg-type]
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
@@ -380,9 +414,18 @@ def resource_roles(resource_id: UUID) -> Response:
"ON rp.privilege_id=p.privilege_id "
"WHERE rr.resource_id=? AND rr.role_created_by=?",
(str(resource_id), str(_token.user.user_id)))
- results = cursor.fetchall()
+ user_created = db_rows_to_roles(cursor.fetchall())
- return db_rows_to_roles(results)
+ cursor.execute(
+ "SELECT ur.user_id, ur.resource_id, r.*, p.* FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE resource_id=? AND user_id=?",
+ (str(resource_id), str(_token.user.user_id)))
+ assigned_to_user = db_rows_to_roles(cursor.fetchall())
+
+ return assigned_to_user + user_created
return jsonify(with_db_connection(__roles__))
@@ -391,7 +434,7 @@ def resource_roles(resource_id: UUID) -> Response:
def resources_authorisation():
"""Get user authorisations for given resource(s):"""
try:
- data = request.json
+ data = request_json()
assert (data and "resource-ids" in data)
resource_ids = tuple(UUID(resid) for resid in data["resource-ids"])
pubres = tuple(
@@ -423,6 +466,14 @@ def resources_authorisation():
"Expected a JSON object with a 'resource-ids' key.")
})
resp.status_code = 400
+ except Exception as _exc:#pylint: disable=[broad-except]
+ app.logger.debug("Generic exception.", exc_info=True)
+ resp = jsonify({
+ "status": "general-exception",
+ "error_description": (
+ "Failed to fetch the user's privileges.")
+ })
+ resp.status_code = 500
return resp
@@ -475,7 +526,8 @@ def get_user_roles_on_resource(name) -> Response:
"email": _token.user.email,
"roles": roles,
}
- token = jwt.encode(jose_header, payload, app.config["SSL_PRIVATE_KEY"])
+ token = jwt.encode(
+ jose_header, payload, newest_jwk(jwks_directory(app)))
response.headers["Authorization"] = f"Bearer {token.decode('utf-8')}"
return response
@@ -507,7 +559,7 @@ def resource_role(resource_id: UUID, role_id: UUID):
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- msg = f"There is data corruption in the database."
+ msg = "There is data corruption in the database."
return jsonify({
"error": "RoleNotFound",
"error_description": msg,
@@ -527,7 +579,6 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
db.connection(app.config["AUTH_DB"]) as conn,
db.cursor(conn) as cursor):
_role = role_by_id(conn, role_id)
- # check_user_editable(_role) # Check whether role is user editable
_authorised = authorised_for(
conn,
@@ -539,14 +590,15 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
"You are not authorised to edit/update this role.")
# Actually unassign the privilege from the role
- privilege_id = request.json.get("privilege_id")
+ privilege_id = request_json().get("privilege_id")
if not privilege_id:
raise AuthorisationError(
"You need to provide a privilege to unassign")
- delete_privilege_from_resource_role(cursor,
- _role,
- privilege_by_id(privilege_id))
+ delete_privilege_from_resource_role(
+ cursor,
+ _role,# type: ignore[arg-type]
+ privilege_by_id(conn, privilege_id))# type: ignore[arg-type]
return jsonify({
"status": "Success",
@@ -570,3 +622,54 @@ def resource_role_users(resource_id: UUID, role_id: UUID):
results = cursor.fetchall() or []
return jsonify(tuple(User.from_sqlite3_row(row) for row in results)), 200
+
+
+@resources.route("/<uuid:resource_id>/roles/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_resource_role(resource_id: UUID):
+ """Create a role to act upon a specific resource."""
+ role_name = request_json().get("role_name", "").strip()
+ if not bool(role_name):
+ raise BadRequest("You must provide the name for the new role.")
+
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ resource = resource_by_id(conn, _token.user, resource_id)
+ if not bool(resource):
+ raise BadRequest("No resource with that ID exists.")
+
+ privileges = privileges_by_ids(conn, request_json().get("privileges", []))
+ if len(privileges) == 0:
+ raise BadRequest(
+ "You must provide at least one privilege for the new role.")
+ role = create_role(cursor,
+ f"{resource.resource_name}::{role_name}",
+ privileges)
+ cursor.execute(
+ "INSERT INTO resource_roles(resource_id, role_created_by, role_id) "
+ "VALUES (:resource_id, :user_id, :role_id)",
+ {
+ "resource_id": str(resource_id),
+ "user_id": str(_token.user.user_id),
+ "role_id": str(role.role_id)
+ })
+
+ return jsonify(asdict(role))
+
+@resources.route("/<uuid:resource_id>/users/<uuid:user_id>/roles", methods=["GET"])
+@require_oauth("profile group resource role")
+def user_resource_roles(resource_id: UUID, user_id: UUID):
+ """Get a specific user's roles on a particular resource."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ if _token.user.user_id != user_id:
+ raise AuthorisationError(
+ "You are not authorised to view the roles this user has.")
+
+ _resource = resource_by_id(conn, _token.user, resource_id)
+ if not bool(_resource):
+ raise BadRequest("No resource was found with the given ID.")
+
+ return jsonify([asdict(role) for role in
+ _user_resource_roles(conn, _token.user, _resource)])
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index e740bfd..6faeaca 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -7,6 +7,7 @@ from typing import Sequence, Iterable, Optional
from pymonad.either import Left, Right, Either
from gn_auth.auth.errors import NotFoundError, AuthorisationError
+from gn_auth.auth.authorisation.resources.base import Resource
from ...db import sqlite3 as db
from ...authentication.users import User
@@ -54,11 +55,14 @@ def db_rows_to_roles(rows) -> tuple[Role, ...]:
if bool(rows) else [])
@authorised_p(
- privileges = ("group:role:create-role",),
+ privileges = ("resource:role:create-role",),
error_description="Could not create role")
def create_role(
- cursor: db.DbCursor, role_name: str,
- privileges: Iterable[Privilege]) -> Role:
+ cursor: db.DbCursor,
+ role_name: str,
+ privileges: Iterable[Privilege],
+ user_editable: bool=True
+) -> Role:
"""
Create a new generic role.
@@ -71,7 +75,7 @@ def create_role(
RETURNS: An immutable `gn3.auth.authorisation.roles.Role` object
"""
- role = Role(uuid4(), role_name, True, tuple(privileges))
+ role = Role(uuid4(), role_name, user_editable, tuple(privileges))
cursor.execute(
"INSERT INTO roles(role_id, role_name, user_editable) VALUES (?, ?, ?)",
@@ -128,6 +132,37 @@ def user_roles(conn: db.DbConnection, user: User) -> Sequence[dict]:
__organise_privileges__, cursor.fetchall(), {}).values())
return tuple()
+
+def user_roles_on_resource(
+ conn: db.DbConnection,
+ user_id: UUID,
+ resource_id: UUID
+) -> tuple[Role, ...]:
+ """Retrieve all roles assigned to a user for a particular resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT ur.resource_id, ur.user_id, r.*, p.* "
+ "FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE ur.user_id=? AND ur.resource_id=?",
+ (str(user_id), str(resource_id)))
+
+ return db_rows_to_roles(cursor.fetchall())
+ return tuple()
+
+
+def user_resource_roles(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource
+) -> tuple[Role, ...]:
+ "Retrieve roles a user has on a particular resource."
+ # TODO: Temporary placeholder to prevent system from breaking.
+ return user_roles_on_resource(conn, user.user_id, resource.resource_id)
+
+
def user_role(conn: db.DbConnection, user: User, role_id: UUID) -> Either:
"""Retrieve a specific non-resource role assigned to the user."""
with db.cursor(conn) as cursor:
@@ -236,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- raise Exception("Data corruption: Expected a single role.")
+ raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised]
return _roles[0]
@@ -244,7 +279,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
def delete_privilege_from_resource_role(
cursor: db.DbCursor,
role: Role,
- privilege_id: str
+ privilege: Privilege
):
"""Delete a privilege from a resource role."""
cursor.execute(
diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py
index 64e79a0..43ca0a2 100644
--- a/gn_auth/auth/authorisation/users/admin/ui.py
+++ b/gn_auth/auth/authorisation/users/admin/ui.py
@@ -1,6 +1,6 @@
"""UI utilities for the auth system."""
from functools import wraps
-from flask import flash, url_for, redirect
+from flask import flash, request, url_for, redirect
from gn_auth.session import logged_in, session_user, clear_session_info
from gn_auth.auth.authorisation.resources.system.models import (
@@ -24,5 +24,5 @@ def is_admin(func):
flash("Expected a system administrator.", "alert-danger")
flash("You have been logged out of the system.", "alert-info")
clear_session_info()
- return redirect(url_for("oauth2.admin.login"))
+ return redirect(url_for("oauth2.admin.login", **dict(request.args)))
return __admin__
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 8ca1e51..9bc1c36 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -3,14 +3,12 @@ import uuid
import json
import random
import string
-from pathlib import Path
from typing import Optional
from functools import partial
from dataclasses import asdict
from urllib.parse import urlparse
from datetime import datetime, timezone, timedelta
-from authlib.jose import KeySet, JsonWebKey
from email_validator import validate_email, EmailNotValidError
from flask import (
flash,
@@ -32,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import (
save_client,
OAuth2Client,
oauth2_clients,
+ update_client_attribute,
client as oauth2_client,
delete_client as _delete_client)
from ....authentication.users import (
@@ -62,7 +61,8 @@ _FORM_GRANT_TYPES_ = ({
@admin.before_request
def update_expires():
"""Update session expiration."""
- if session.session_info() and not session.update_expiry():
+ if (session.session_info() and not session.update_expiry(
+ int(app.config.get("SESSION_EXPIRY_MINUTES", 10)))):
flash("Session has expired. Logging out...", "alert-warning")
session.clear_session_info()
return redirect(url_for("oauth2.admin.login"))
@@ -96,8 +96,9 @@ def login():
session.update_session_info(
user=asdict(user),
expires=(
- datetime.now(tz=timezone.utc) + timedelta(minutes=10)))
- return redirect(url_for(next_uri))
+ datetime.now(tz=timezone.utc) + timedelta(minutes=int(
+ app.config.get("SESSION_EXPIRY_MINUTES", 10)))))
+ return redirect(url_for(next_uri, **dict(request.args)))
raise NotFoundError(error_message)
except NotFoundError as _nfe:
flash(error_message, "alert-danger")
@@ -176,6 +177,9 @@ def check_register_client_form(form):
"scope[]",
"You need to select at least one scope option."),)
+ if not uri_valid(form.get("client_jwk_uri", "")):
+ errors = errors + ("The provided client's public JWKs URI is invalid.",)
+
errors = tuple(item for item in errors if item is not None)
if bool(errors):
raise RegisterClientError(errors)
@@ -193,7 +197,7 @@ def register_client():
if request.method == "GET":
return render_template(
"admin/register-client.html",
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
users=with_db_connection(__list_users__),
granttypes=_FORM_GRANT_TYPES_,
current_user=session.session_user())
@@ -223,7 +227,8 @@ def register_client():
"default_redirect_uri": default_redirect_uri,
"redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(),
"response_type": __response_types__(tuple(grant_types)),
- "scope": form.getlist("scope[]")
+ "scope": form.getlist("scope[]"),
+ "public-jwks-uri": form.get("client_jwk_uri", "")
},
user = with_db_connection(partial(
user_by_id, user_id=uuid.UUID(form["user"])))
@@ -257,111 +262,9 @@ def view_client(client_id: uuid.UUID):
return render_template(
"admin/view-oauth2-client.html",
client=with_db_connection(partial(oauth2_client, client_id=client_id)),
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
granttypes=_FORM_GRANT_TYPES_)
-@admin.route("/register-client-public-key", methods=["POST"])
-@is_admin
-def register_client_public_key():
- """Register a client's SSL key"""
- form = request.form
- admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard"))
- view_client_uri = redirect(url_for("oauth2.admin.view_client",
- client_id=form["client_id"]))
- if not bool(form.get("client_id")):
- flash("No client selected.", "alert-danger")
- return admin_dashboard_uri
-
- try:
- _client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if _client.is_nothing():
- raise ValueError("No such client.")
- _client = _client.value
- except ValueError:
- flash("Invalid client ID provided.", "alert-danger")
- return admin_dashboard_uri
- try:
- _key = JsonWebKey.import_key(form["client_ssl_key"].strip())
- except ValueError:
- flash("Invalid key provided!", "alert-danger")
- return view_client_uri
-
- keypath = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]).joinpath(
- f"{_key.thumbprint()}.pem")
- if not keypath.exists():
- with open(keypath, mode="w", encoding="utf8") as _kpth:
- _kpth.write(form["client_ssl_key"])
-
- with_db_connection(partial(save_client, the_client=OAuth2Client(
- client_id=_client.client_id,
- client_secret=_client.client_secret,
- client_id_issued_at=_client.client_id_issued_at,
- client_secret_expires_at=_client.client_secret_expires_at,
- client_metadata={
- **_client.client_metadata,
- "public_keys": list(set(
- _client.client_metadata.get("public_keys", []) +
- [str(keypath)]))},
- user=_client.user)))
- flash("Client key successfully registered.", "alert-success")
- return view_client_uri
-
-
-@admin.route("/delete-client-public-key", methods=["POST"])
-@is_admin
-def delete_client_public_key():
- """Delete a client's SSL key"""
- form = request.form
- admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard"))
- view_client_uri = redirect(url_for("oauth2.admin.view_client",
- client_id=form["client_id"]))
- if not bool(form.get("client_id")):
- flash("No client selected.", "alert-danger")
- return admin_dashboard_uri
-
- try:
- _client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if _client.is_nothing():
- raise ValueError("No such client.")
- _client = _client.value
- except ValueError:
- flash("Invalid client ID provided.", "alert-danger")
- return admin_dashboard_uri
-
- if form.get("ssl_key", None) is None:
- flash("The key must be provided.", "alert-danger")
- return view_client_uri
-
- try:
- def find_by_kid(keyset: KeySet, kid: str) -> JsonWebKey:
- for key in keyset.keys:
- if key.thumbprint() == kid:
- return key
- raise ValueError('Invalid JSON Web Key Set')
- _key = find_by_kid(_client.jwks, form.get("ssl_key"))
- except ValueError:
- flash("Could not delete: No such public key.", "alert-danger")
- return view_client_uri
-
- _keys = (_key for _key in _client.jwks.keys
- if _key.thumbprint() != form["ssl_key"])
- _keysdir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"])
- with_db_connection(partial(save_client, the_client=OAuth2Client(
- client_id=_client.client_id,
- client_secret=_client.client_secret,
- client_id_issued_at=_client.client_id_issued_at,
- client_secret_expires_at=_client.client_secret_expires_at,
- client_metadata={
- **_client.client_metadata,
- "public_keys": list(set(
- _keysdir.joinpath(f"{_key.thumbprint()}.pem")
- for _key in _keys))},
- user=_client.user)))
- flash("Key deleted.", "alert-success")
- return view_client_uri
-
@admin.route("/edit-client", methods=["POST"])
@is_admin
@@ -389,7 +292,8 @@ def edit_client():
[form["redirect_uri"]] +
form["other_redirect_uris"].split("\r\n"))),
"grant_types": form.getlist("grants[]"),
- "scope": form.getlist("scope[]")
+ "scope": form.getlist("scope[]"),
+ "public-jwks-uri": form.get("client_jwk_uri", "")
}
with_db_connection(partial(save_client, the_client=OAuth2Client(
the_client.client_id,
@@ -418,3 +322,37 @@ def delete_client():
"successfully."),
"alert-success")
return redirect(url_for("oauth2.admin.list_clients"))
+
+
+@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"])
+@is_admin
+def change_client_secret(client_id: uuid.UUID):
+ """Enable changing of a client's secret."""
+ def __no_client__():
+ # Calling the function causes the flash to be evaluated
+ # flash("No such client was found!", "alert-danger")
+ return redirect(url_for("oauth2.admin.list_clients"))
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ if request.method == "GET":
+ return oauth2_client(
+ conn, client_id=client_id
+ ).maybe(__no_client__(), lambda _client: render_template(
+ "admin/confirm-change-client-secret.html",
+ client=_client
+ ))
+
+ _raw = random_string()
+ return oauth2_client(
+ conn, client_id=client_id
+ ).then(
+ lambda _client: save_client(
+ conn,
+ update_client_attribute(
+ _client, "client_secret", hash_password(_raw)))
+ ).then(
+ lambda _client: render_template(
+ "admin/registered-client.html",
+ client=_client,
+ client_secret=_raw)
+ ).maybe(__no_client__(), lambda resp: resp)
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index b4a24f3..63443ef 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool:
def __toggle_boolean_field__(
rconn: Redis, email: str, field: str):
"""Toggle the valuen of a boolean field"""
- mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")
+ mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore
if bool(mig_dict):
rconn.hset("migratable-accounts", email,
json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
@@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis):
"resources_migrated": False
} for account in (
acct for acct in
- (json.loads(usr) for usr in rconn.hgetall("users").values())
+ (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore
if (bool(acct.get("email_address", False)) and
__valid_email__(acct["email_address"])))
}
@@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
accounts = rconn.hgetall("migratable-accounts")
if accounts:
return {
- key: json.loads(value) for key, value in accounts.items()
+ key: json.loads(value) for key, value in accounts.items() # type: ignore
}
return __build_email_uuid_bridge__(rconn)
@@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple:
"""Retrieve any old collections relating to the user."""
return tuple(parse_collection(coll) for coll in
json.loads(rconn.hget(
- __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))
+ __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore
def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]:
"""Retrieve current user collections."""
collections = tuple(parse_collection(coll) for coll in json.loads(
rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or
- "[]"))
+ "[]")) # type: ignore
old_accounts = __retrieve_old_accounts__(rconn)
if (user.email in old_accounts and
not old_accounts[user.email]["collections-migrated"]):
@@ -205,8 +205,10 @@ def add_traits(rconn: Redis,
mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id)
__raise_if_not_single_collection__(user, collection_id, mod_col)
new_members = tuple(set(tuple(mod_col[0]["members"]) + traits))
+ now = datetime.utcnow()
new_coll = {
**mod_col[0],
+ "changed": now,
"members": new_members,
"num_members": len(new_members)
}
@@ -233,8 +235,10 @@ def remove_traits(rconn: Redis,
__raise_if_not_single_collection__(user, collection_id, mod_col)
new_members = tuple(
trait for trait in mod_col[0]["members"] if trait not in traits)
+ now = datetime.utcnow()
new_coll = {
**mod_col[0],
+ "changed": now,
"members": new_members,
"num_members": len(new_members)
}
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index eeae91d..f619c3d 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -113,6 +113,7 @@ def import_anonymous() -> Response:
anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr]
anon_colls = user_collections(redisconn, User(
anon_id, "anon@ymous.user", "Anonymous User"))
+ anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0)
save_collections(
redisconn,
token.user,
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
index 57bc564..5c11f34 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -1,20 +1,26 @@
"""Functions for handling masquerade."""
-from uuid import uuid4
from functools import wraps
from datetime import datetime
+from authlib.jose import jwt
from flask import current_app as app
from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory
+from gn_auth.auth.authentication.oauth2.grants.refresh_token_grant import (
+ RefreshTokenGrant)
+from gn_auth.auth.authentication.oauth2.models.jwtrefreshtoken import (
+ JWTRefreshToken,
+ save_refresh_token)
+
from ...roles.models import user_roles
from ....db import sqlite3 as db
from ....authentication.users import User
-from ....authentication.oauth2.models.oauth2token import (
- OAuth2Token, save_token)
+from ....authentication.oauth2.models.oauth2token import OAuth2Token
-__FIVE_HOURS__ = (60 * 60 * 5)
+__FIVE_HOURS__ = 60 * 60 * 5
def can_masquerade(func):
"""Security decorator."""
@@ -31,9 +37,13 @@ def can_masquerade(func):
conn = kwargs["conn"]
token = kwargs["original_token"]
- masq_privs = [priv for role in user_roles(conn, token.user)
- for priv in role.privileges
- if priv.privilege_id == "system:user:masquerade"]
+ masq_privs = []
+ for roles in user_roles(conn, token.user):
+ for role in roles["roles"]:
+ privileges = [p for p in role.privileges
+ if p.privilege_id == "system:user:masquerade"]
+ masq_privs.extend(privileges)
+
if len(masq_privs) == 0:
raise ForbiddenAccess(
"You do not have the ability to masquerade as another user.")
@@ -46,22 +56,30 @@ def masquerade_as(
original_token: OAuth2Token,
masqueradee: User) -> OAuth2Token:
"""Get a token that enables `masquerader` to act as `masqueradee`."""
- token_details = app.config["OAUTH2_SERVER"].generate_token(
+ scope = original_token.get_scope().replace(
+ # Do not allow more than one level of masquerading
+ "masquerade", "").strip()
+ new_token = app.config["OAUTH2_SERVER"].generate_token(
client=original_token.client,
- grant_type="authorization_code",
+ grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
user=masqueradee,
- expires_in=__FIVE_HOURS__,
- include_refresh_token=True)
- new_token = OAuth2Token(
- token_id=uuid4(),
+ expires_in=original_token.get_expires_in(),
+ include_refresh_token=True,
+ scope=scope)
+ _jwt = jwt.decode(
+ new_token["access_token"],
+ newest_jwk_with_rotation(
+ jwks_directory(app),
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])))
+ save_refresh_token(conn, JWTRefreshToken(
+ token=new_token["refresh_token"],
client=original_token.client,
- token_type=token_details["token_type"],
- access_token=token_details["access_token"],
- refresh_token=token_details.get("refresh_token"),
- scope=original_token.scope,
+ user=masqueradee,
+ issued_with=_jwt["jti"],
+ issued_at=datetime.fromtimestamp(_jwt["iat"]),
+ expires=datetime.fromtimestamp(
+ int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN),
+ scope=scope,
revoked=False,
- issued_at=datetime.now(),
- expires_in=token_details["expires_in"],
- user=masqueradee)
- save_token(conn, new_token)
+ parent_of=None))
return new_token
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 276859a..8b897f2 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -28,22 +28,17 @@ def masquerade() -> Response:
masq_user = with_db_connection(partial(
user_by_id, user_id=masqueradee_id))
+
def __masq__(conn):
new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
return new_token
- def __dump_token__(tok):
- return {
- key: value for key, value in (tok._asdict().items())
- if key in ("access_token", "refresh_token", "expires_in",
- "token_type")
- }
+
return jsonify({
"original": {
- "user": token.user._asdict(),
- "token": __dump_token__(token)
+ "user": asdict(token.user)
},
"masquerade_as": {
"user": asdict(masq_user),
- "token": __dump_token__(with_db_connection(__masq__))
+ "token": with_db_connection(__masq__)
}
})
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..ef3ce7f 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,6 +1,8 @@
"""Functions for acting on users."""
import uuid
+from typing import Union
from functools import reduce
+from datetime import datetime, timedelta
from ..roles.models import Role
from ..checks import authorised_p
@@ -9,14 +11,72 @@ from ..privileges import Privilege
from ...db import sqlite3 as db
from ...authentication.users import User
+
+def __process_age_clause__(age_desc: str) -> tuple[str, int]:
+ """Process the age clause and parameter for 'LIST USERS' query."""
+ _today = datetime.now()
+ _clause = "created"
+ _parts = age_desc.split(" ")
+ _multipliers = {
+ # Temporary hack before dateutil module can make it to our deployment.
+ "days": 1,
+ "months": 30,
+ "years": 365
+ }
+ assert len(_parts) in (3, 4), "Invalid age descriptor!"
+
+ _param = int((
+ _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]})
+ ).timestamp())
+
+ match _parts[0]:
+ case "older":
+ return "created < :created", _param
+ case "younger":
+ return "created > :created", _param
+ case "exactly":
+ return "created = :created", _param
+ case _:
+ raise Exception("Invalid age descriptor.")
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[list[str], dict[str, Union[int, str]]]:
+ """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+ clauses = []
+ params = {}
+ if bool(kwargs.get("email", "").strip()):
+ clauses = clauses + ["email LIKE :email"]
+ params["email"] = f'%{kwargs["email"].strip()}%'
+
+ if bool(kwargs.get("name", "").strip()):
+ clauses = clauses + ["name LIKE :name"]
+ params["name"] = f'%{kwargs["name"].strip()}%'
+
+ if bool(kwargs.get("verified", "").strip()):
+ clauses = clauses + ["verified=:verified"]
+ params["verified"] = 1 if kwargs["verified"].strip() == "yes" else "no"
+
+ if bool(kwargs.get("age", "").strip()):
+ _clause, _param = __process_age_clause__(kwargs["age"].strip())
+ clauses = clauses + [_clause]
+ params["created"] = _param
+
+ return clauses, params
+
+
@authorised_p(
("system:user:list",),
"You do not have the appropriate privileges to list users.",
oauth2_scope="profile user")
-def list_users(conn: db.DbConnection) -> tuple[User, ...]:
+def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]:
"""List out all users."""
+ _query = "SELECT * FROM users"
+ _clauses, _params = __list_user_clauses_and_params__(**kwargs)
+ if len(_clauses) > 0:
+ _query = _query + " WHERE " + " AND ".join(_clauses)
+
with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM users")
+ cursor.execute(_query, _params)
return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
def __build_resource_roles__(rows):
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 1d3b128..be4296b 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,12 +1,13 @@
"""User authorisation endpoints."""
+import uuid
import sqlite3
import secrets
-import datetime
import traceback
from typing import Any
from functools import partial
from dataclasses import asdict
from urllib.parse import urljoin
+from datetime import datetime, timedelta
from email.headerregistry import Address
from email_validator import validate_email, EmailNotValidError
from flask import (
@@ -22,9 +23,12 @@ from flask import (
from gn_auth.smtp import send_message, build_email_message
+from gn_auth.auth.requests import request_json
+
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
@@ -36,6 +40,7 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
+ AuthorisationError,
UserRegistrationError)
@@ -111,6 +116,30 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + \
+ f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -121,8 +150,8 @@ def send_verification_email(
"""Send an email verification message."""
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
- generated = datetime.datetime.now()
- expiration_minutes = 15
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -134,7 +163,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -146,12 +176,13 @@ def send_verification_email(
"generated": int(generated.timestamp()),
"expires": int(
(generated +
- datetime.timedelta(
+ timedelta(
minutes=expiration_minutes)).timestamp())
})
- send_message(smtp_user=current_app.config["SMTP_USER"],
- smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ send_message(smtp_user=current_app.config.get("SMTP_USER", ""),
+ smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""),
message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
to_addresses=(user_address(user),),
subject=subject,
txtmessage=__render__("emails/verify-email.txt"),
@@ -166,7 +197,7 @@ def register_user() -> Response:
__assert_not_logged_in__(conn)
try:
- form = request.form
+ form = request_json()
email = validate_email(form.get("email", "").strip(),
check_deliverability=True)
password = validate_password(
@@ -176,7 +207,7 @@ def register_user() -> Response:
with db.cursor(conn) as cursor:
user, _hashed_password = set_user_password(
cursor, save_user(
- cursor, email["email"], user_name), password)
+ cursor, email["email"], user_name), password) # type: ignore
assign_default_roles(cursor, user)
send_verification_email(conn,
user,
@@ -185,14 +216,14 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -204,7 +235,7 @@ def delete_verification_code(cursor, code: str):
@users.route("/verify", methods=["GET", "POST"])
def verify_user():
"""Verify users are not bots."""
- form = request.form
+ form = request_json()
loginuri = redirect(url_for(
"oauth2.auth.authorise",
response_type=(request.args.get("response_type")
@@ -233,11 +264,12 @@ def verify_user():
return loginuri
results = results[0]
- if (datetime.datetime.fromtimestamp(
- int(results["expires"])) < datetime.datetime.now()):
+ if (datetime.fromtimestamp(
+ int(results["expires"])) < datetime.now()):
delete_verification_code(cursor, verificationcode)
flash("Invalid verification code: code has expired.",
"alert-danger")
+ return loginuri
# Code is good!
delete_verification_code(cursor, verificationcode)
@@ -301,27 +333,59 @@ def user_join_request_exists():
@require_oauth("profile user")
def list_all_users() -> Response:
"""List all the users."""
- with require_oauth.acquire("profile group") as _the_token:
- return jsonify(tuple(
- asdict(user) for user in with_db_connection(list_users)))
+ _kwargs = {
+ key: value
+ for key, value in request.json.items()
+ if key in ("email", "name", "verified", "age")
+ }
+
+ with (require_oauth.acquire("profile group") as _the_token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ _users = list_users(conn, **_kwargs)
+ _start = int(_kwargs.get("start", "0"))
+ _length = int(_kwargs.get("length", "0"))
+ cursor.execute("SELECT COUNT(*) FROM users")
+ _total_users = int(cursor.fetchone()["COUNT(*)"])
+ return jsonify({
+ "users": tuple(asdict(user) for user in
+ (_users[_start:_start+_length]
+ if _length else _users)),
+ "total-users": _total_users,
+ "total-filtered": len(_users)
+ })
@users.route("/handle-unverified", methods=["POST"])
def handle_unverified():
"""Handle case where user tries to login but is unverified"""
- form = request.form
+ email = request.args["email"]
# TODO: Maybe have a GN2_URI setting here?
# or pass the client_id here?
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute(
+ "DELETE FROM user_verification_codes WHERE expires <= ?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT u.user_id, u.email, uvc.* FROM users AS u "
+ "INNER JOIN user_verification_codes AS uvc "
+ "ON u.user_id=uvc.user_id "
+ "WHERE u.email=?",
+ (email,))
+ token_found = bool(cursor.fetchone())
+
return render_template(
"users/unverified-user.html",
- email=form.get("user:email"),
+ email=email,
response_type=request.args["response_type"],
client_id=request.args["client_id"],
- redirect_uri=request.args["redirect_uri"])
+ redirect_uri=request.args["redirect_uri"],
+ token_found=token_found)
@users.route("/send-verification", methods=["POST"])
def send_verification_code():
"""Send verification code email."""
- form = request.form
+ form = request_json()
with (db.connection(current_app.config["AUTH_DB"]) as conn,
db.cursor(conn) as cursor):
user = user_by_email(conn, form["user_email"])
@@ -348,3 +412,221 @@ def send_verification_code():
})
resp.code = 400
return resp
+
+
+def send_forgot_password_email(
+ conn,
+ user: User,
+ client_id: uuid.UUID,
+ redirect_uri: str,
+ response_type: str
+):
+ """Send the 'forgot-password' email."""
+ subject="GeneNetwork: Change Your Password"
+ token = secrets.token_urlsafe(64)
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
+ def __render__(template):
+ return render_template(template,
+ subject=subject,
+ forgot_password_uri=urljoin(
+ request.url,
+ url_for("oauth2.users.change_password",
+ forgot_password_token=token,
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)),
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
+
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ ("INSERT OR REPLACE INTO "
+ "forgot_password_tokens(user_id, token, generated, expires) "
+ "VALUES (:user_id, :token, :generated, :expires)"),
+ {
+ "user_id": str(user.user_id),
+ "token": token,
+ "generated": int(generated.timestamp()),
+ "expires": int(
+ (generated +
+ timedelta(
+ minutes=expiration_minutes)).timestamp())
+ })
+
+ send_message(smtp_user=current_app.config["SMTP_USER"],
+ smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
+ to_addresses=(user_address(user),),
+ subject=subject,
+ txtmessage=__render__("emails/forgot-password.txt"),
+ htmlmessage=__render__("emails/forgot-password.html")),
+ host=current_app.config["SMTP_HOST"],
+ port=current_app.config["SMTP_PORT"])
+
+
+@users.route("/forgot-password", methods=["GET", "POST"])
+def forgot_password():
+ """Enable user to request password change."""
+ if request.method == "GET":
+ return render_template("users/forgot-password.html",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"])
+
+ form = request.form
+ email = form.get("email", "").strip()
+ if not bool(email):
+ flash("You MUST provide an email.", "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_email(conn, form["email"])
+ if not bool(user):
+ flash("We could not find an account with that email.",
+ "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ send_forgot_password_email(conn,
+ user,
+ request.args["client_id"],
+ request.args["redirect_uri"],
+ request.args["response_type"])
+ return render_template("users/forgot-password-token-send-success.html",
+ email=form["email"])
+
+
+@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"])
+def change_password(forgot_password_token):
+ """Enable user to perform password change."""
+ login_page = redirect(url_for("oauth2.auth.authorise",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"]))
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt "
+ "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?",
+ (forgot_password_token,))
+ token = cursor.fetchone()
+ if request.method == "GET":
+ if bool(token):
+ return render_template(
+ "users/change-password.html",
+ email=token["email"],
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token)
+ flash("Invalid Token: We cannot change your password!",
+ "alert-danger")
+ return login_page
+
+ password = request.form["password"]
+ confirm_password = request.form["confirm-password"]
+ change_password_page = redirect(url_for(
+ "oauth2.users.change_password",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token))
+ if bool(password) and bool(confirm_password):
+ if password == confirm_password:
+ _user, _hashed_password = set_user_password(
+ cursor, user_by_email(conn, token["email"]), password)
+ cursor.execute(
+ "DELETE FROM forgot_password_tokens WHERE token=?",
+ (forgot_password_token,))
+ flash("Password changed successfully!", "alert-success")
+ return login_page
+
+ flash("Passwords do not match!", "alert-danger")
+ return change_password_page
+
+ flash("Both the password and its confirmation MUST be provided!",
+ "alert-danger")
+ return change_password_page
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+ """Delete the specified user."""
+ with (require_oauth.acquire("profile") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ if not authorised_for2(conn,
+ _token.user,
+ system_resource(conn),
+ ("system:user:delete-user",)):
+ raise AuthorisationError(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set((str(_token.user.user_id),))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _delete = tuple(uid for uid in _user_ids if uid not in _non_deletable)
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ _diff = len(_user_ids) - _deleted_rows
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _diff,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (f" Some users could not be deleted." if _diff > 0 else ""))
+ })
+
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": len(_user_ids),
+ "error": "Zero users were deleted",
+ "error_description": (
+ "Either no users were selected or all the selected users are "
+ "system administrators, group members, or resource owners.")
+ }), 400
diff --git a/gn_auth/auth/db/mariadb.py b/gn_auth/auth/db/mariadb.py
deleted file mode 100644
index a36e9d3..0000000
--- a/gn_auth/auth/db/mariadb.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Connections to MariaDB"""
-import logging
-import traceback
-import contextlib
-from urllib.parse import urlparse
-from typing import Any, Tuple, Protocol, Iterator
-
-import MySQLdb as mdb
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self, *args, **kwargs) -> Any:
- """A cursor object"""
-
- def commit(self, *args, **kwargs) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
-
-def parse_db_url(sql_uri: str) -> Tuple:
- """Parse SQL_URI env variable note:there is a default value for SQL_URI so a
- tuple result is always expected"""
- parsed_db = urlparse(sql_uri)
- return (
- parsed_db.hostname, parsed_db.username, parsed_db.password,
- parsed_db.path[1:], parsed_db.port)
-
-@contextlib.contextmanager
-def database_connection(sql_uri) -> Iterator[DbConnection]:
- """Connect to MySQL database."""
- host, user, passwd, db_name, port = parse_db_url(sql_uri)
- connection = mdb.connect(db=db_name,
- user=user,
- passwd=passwd or '',
- host=host,
- port=port or 3306)
- try:
- yield connection
- except mdb.Error as _mdb_err:
- logging.debug(traceback.format_exc())
- connection.rollback()
- finally:
- connection.commit()
- connection.close()
diff --git a/gn_auth/auth/jwks.py b/gn_auth/auth/jwks.py
new file mode 100644
index 0000000..7381000
--- /dev/null
+++ b/gn_auth/auth/jwks.py
@@ -0,0 +1,86 @@
+"""Utilities dealing with JSON Web Keys (JWK)"""
+import os
+from pathlib import Path
+from typing import Any, Union
+from datetime import datetime, timedelta
+
+from flask import Flask
+from authlib.jose import JsonWebKey
+from pymonad.either import Left, Right, Either
+
+def jwks_directory(app: Flask) -> Path:
+ """Compute the directory where the JWKs are stored."""
+ appsecretsdir = Path(app.config["GN_AUTH_SECRETS"]).parent
+ if appsecretsdir.exists() and appsecretsdir.is_dir():
+ jwksdir = Path(appsecretsdir, "jwks/")
+ if not jwksdir.exists():
+ jwksdir.mkdir()
+ return jwksdir
+ raise ValueError(
+ "The `appsecretsdir` value should be a directory that actually exists.")
+
+
+def generate_and_save_private_key(
+ storagedir: Path,
+ kty: str = "RSA",
+ crv_or_size: Union[str, int] = 2048,
+ options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),)
+) -> JsonWebKey:
+ """Generate a private key and save to `storagedir`."""
+ privatejwk = JsonWebKey.generate_key(
+ kty, crv_or_size, dict(options), is_private=True)
+ keyname = f"{privatejwk.thumbprint()}.private.pem"
+ with open(Path(storagedir, keyname), "wb") as pemfile:
+ pemfile.write(privatejwk.as_pem(is_private=True))
+
+ return privatejwk
+
+
+def pem_to_jwk(filepath: Path) -> JsonWebKey:
+ """Parse a PEM file into a JWK object."""
+ with open(filepath, "rb") as pemfile:
+ return JsonWebKey.import_key(pemfile.read())
+
+
+def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]:
+ """A sorted list of the JWK file paths with their creation timestamps."""
+ return tuple(sorted(((os.stat(keypath).st_ctime, keypath)
+ for keypath in (Path(storagedir, keyfile)
+ for keyfile in os.listdir(storagedir)
+ if keyfile.endswith(".pem"))),
+ key=lambda tpl: tpl[0]))
+
+
+def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]:
+ """
+ List all the JWKs in a particular directory in the order they were created.
+ """
+ return tuple(pem_to_jwk(keypath) for ctime,keypath in
+ __sorted_jwks_paths__(storagedir))
+
+
+def newest_jwk(storagedir: Path) -> Either:
+ """
+ Return an Either monad with the newest JWK or a message if none exists.
+ """
+ existingkeys = __sorted_jwks_paths__(storagedir)
+ if len(existingkeys) > 0:
+ return Right(pem_to_jwk(existingkeys[-1][1]))
+ return Left("No JWKs exist")
+
+
+def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey:
+ """
+ Retrieve the latests JWK, creating a new one if older than `keyage` days.
+ """
+ def newer_than_days(jwkey):
+ filestat = os.stat(Path(
+ jwksdir, f"{jwkey.as_dict()['kid']}.private.pem"))
+ oldesttimeallowed = (datetime.now() - timedelta(days=keyage))
+ if filestat.st_ctime < (oldesttimeallowed.timestamp()):
+ return Left("JWK is too old!")
+ return jwkey
+
+ return newest_jwk(jwksdir).then(newer_than_days).either(
+ lambda _errmsg: generate_and_save_private_key(jwksdir),
+ lambda key: key)
diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py
new file mode 100644
index 0000000..cd939dd
--- /dev/null
+++ b/gn_auth/auth/requests.py
@@ -0,0 +1,14 @@
+"""Utilities to deal with requests."""
+import werkzeug
+from flask import request
+
+def request_json() -> dict:
+ """Retrieve the JSON sent in a request."""
+ try:
+ json_data = request.json
+ # KLUDGE: We have this check here since request.json has the
+ # type Any | None; see:
+ # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545>
+ return json_data if isinstance(json_data, dict) else {}
+ except werkzeug.exceptions.UnsupportedMediaType:
+ return dict(request.form) or {}
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 17fc94b..6867f38 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -11,7 +11,6 @@ from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
from .authorisation.resources.system.views import system
-from .authorisation.resources.inbredset.views import iset
oauth2 = Blueprint("oauth2", __name__)
@@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group")
oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
-oauth2.register_blueprint(iset, url_prefix="/resource/inbredset")
diff --git a/gn_auth/debug.py b/gn_auth/debug.py
new file mode 100644
index 0000000..6b7173b
--- /dev/null
+++ b/gn_auth/debug.py
@@ -0,0 +1,22 @@
+"""Debug utilities"""
+import logging
+from flask import current_app
+
+__this_module_name__ = __name__
+
+
+# pylint: disable=invalid-name
+def getLogger(name: str):
+ """Return a logger"""
+ return (
+ logging.getLogger(name)
+ if not bool(current_app)
+ else current_app.logger)
+
+def __pk__(*args):
+ """Format log entry"""
+ value = args[-1]
+ title_vals = " => ".join(args[0:-1])
+ logger = getLogger(__this_module_name__)
+ logger.debug("%s: %s", title_vals, value)
+ return value
diff --git a/gn_auth/errors.py b/gn_auth/errors.py
index d50d7c1..4b6007a 100644
--- a/gn_auth/errors.py
+++ b/gn_auth/errors.py
@@ -8,7 +8,9 @@ from gn_auth.auth.errors import AuthorisationError
def add_trace(exc: Exception, errobj: dict) -> dict:
"""Add the traceback to the error handling object."""
- current_app.logger.debug(traceback.format_exception(exc))
+ current_app.logger.error("Endpoint: %s\n%s",
+ request.url,
+ traceback.format_exception(exc))
return {
**errobj,
"error-trace": "".join(traceback.format_exception(exc))
@@ -16,6 +18,7 @@ def add_trace(exc: Exception, errobj: dict) -> dict:
def page_not_found(exc):
"""404 handler."""
+ current_app.logger.error(f"Page '{request.url}' was not found.", exc_info=True)
content_type = request.content_type
if bool(content_type) and content_type.lower() == "application/json":
return jsonify(add_trace(exc, {
@@ -28,20 +31,27 @@ def page_not_found(exc):
def handle_general_exception(exc: Exception):
+ """Handle generic unhandled exceptions."""
+ current_app.logger.error("Error occurred!", exc_info=True)
content_type = request.content_type
if bool(content_type) and content_type.lower() == "application/json":
+ exc_args = [str(x) for x in exc.args]
msg = ("The following exception was raised while attempting to access "
- f"{request.url}: {exc.args[0]}")
+ f"{request.url}: {' '.join(exc_args)}")
return jsonify(add_trace(exc, {
"error": type(exc).__name__,
"error_description": msg
})), 500
- return render_template("500.html", page=request.url), 500
+ return render_template("50x.html",
+ page=request.url,
+ error=exc,
+ trace=traceback.format_exception(exc)), 500
def handle_authorisation_error(exc: AuthorisationError):
"""Handle AuthorisationError if not handled anywhere else."""
+ current_app.logger.error("Error occurred!", exc_info=True)
current_app.logger.error(exc)
return jsonify(add_trace(exc, {
"error": type(exc).__name__,
diff --git a/gn_auth/hooks.py b/gn_auth/hooks.py
new file mode 100644
index 0000000..bd7380b
--- /dev/null
+++ b/gn_auth/hooks.py
@@ -0,0 +1,68 @@
+"""Authorisation hooks implementation"""
+import functools
+from typing import List
+
+from flask import request_finished
+from flask import request, current_app
+
+from gn_auth.auth.db import sqlite3 as db
+
+def register_hooks(app):
+ """Initialise hooks system on the application."""
+ request_finished.connect(edu_domain_hook, app)
+
+
+def handle_register_request(func):
+ """Decorator for handling user registration hooks."""
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ if request.method == "POST" and request.endpoint == "oauth2.users.register_user":
+ return func(*args, **kwargs)
+ else:
+ return lambda *args, **kwargs: None
+ return wrapper
+
+
+@handle_register_request
+def edu_domain_hook(_sender, response, **_extra):
+ """Hook to run whenever a user with a `.edu` domain registers."""
+ if response.status_code >= 400:
+ return
+ data = request.get_json()
+ if data is None or "email" not in data or not data["email"].endswith("edu"):
+ return
+ registered_email = data["email"]
+ apply_edu_role(registered_email)
+
+
+def apply_edu_role(email):
+ """Assign 'hook-role-from-edu-domain' to user."""
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT user_id FROM users WHERE email= ?", (email,) )
+ user_result = cursor.fetchone()
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='hook-role-from-edu-domain'")
+ role_result = cursor.fetchone()
+ resource_ids = get_resources_for_edu_domain(cursor)
+ if user_result is None or role_result is None:
+ return
+ user_id = user_result[0]
+ role_id = role_result[0]
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES(:user_id, :role_id, :resource_id)",
+ tuple({
+ "user_id": user_id,
+ "role_id": role_id,
+ "resource_id": resource_id
+ } for resource_id in resource_ids))
+
+
+def get_resources_for_edu_domain(cursor) -> List[int]:
+ """FIXME: I still haven't figured out how to get resources to be assigned to edu domain"""
+ resources_query = """
+ SELECT resource_id FROM resources INNER JOIN resource_categories USING(resource_category_id) WHERE resource_categories.resource_category_key IN ('genotype', 'phenotype', 'mrna')
+ """
+ cursor.execute(resources_query)
+ resource_ids = [x[0] for x in cursor.fetchall()]
+ return resource_ids
diff --git a/gn_auth/jobs.py b/gn_auth/jobs.py
index 8f9f4f0..7cd5945 100644
--- a/gn_auth/jobs.py
+++ b/gn_auth/jobs.py
@@ -24,7 +24,7 @@ def job(redisconn: Redis, job_id: UUID) -> Either:
if the_job:
return Right({
key: json.loads(value, object_hook=jed.custom_json_decoder)
- for key, value in the_job.items()
+ for key, value in the_job.items() # type: ignore
})
return Left({
"error": "NotFound",
diff --git a/gn_auth/misc_views.py b/gn_auth/misc_views.py
index bd2ad62..2abad4a 100644
--- a/gn_auth/misc_views.py
+++ b/gn_auth/misc_views.py
@@ -2,9 +2,10 @@
Miscellaneous top-level views that have nothing to do with the application's
functionality.
"""
+import os
from pathlib import Path
-from flask import Blueprint
+from flask import Blueprint, current_app as app, send_from_directory
misc = Blueprint("misc", __name__)
@@ -16,3 +17,11 @@ def version():
with open(version_file, encoding="utf-8") as verfl:
return verfl.read().strip()
return "0.0.0"
+
+
+@misc.route("/favicon.ico", methods=["GET"])
+def favicon():
+ """Return the favicon."""
+ return send_from_directory(os.path.join(app.root_path, "static"),
+ "images/CITGLogo.png",
+ mimetype="image/png")
diff --git a/gn_auth/session.py b/gn_auth/session.py
index 7226ac5..39f6959 100644
--- a/gn_auth/session.py
+++ b/gn_auth/session.py
@@ -47,11 +47,11 @@ def session_expired() -> bool:
return now >= session[__SESSION_KEY__]["expires"]
return True
-def update_expiry() -> bool:
+def update_expiry(minutes: int = 10) -> bool:
"""Update the session expiry and return a boolean indicating success."""
if not session_expired():
now = datetime.now(tz=timezone.utc)
- session[__SESSION_KEY__]["expires"] = now + timedelta(minutes=10)
+ session[__SESSION_KEY__]["expires"] = now + timedelta(minutes=minutes)
return True
return False
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index 7dc0105..d59e997 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -8,6 +8,9 @@ LOGLEVEL = "WARNING"
SECRET_KEY = ""
GN_AUTH_SECRETS = None # Set this to path to secrets file
+# Session settings
+SESSION_EXPIRY_MINUTES = 10
+
# Database settings
SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl"
AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db"
@@ -18,9 +21,11 @@ REDIS_URI = "redis://localhost:6379/0"
REDIS_JOB_QUEUE = "GN_AUTH::job-queue"
# OAuth2 settings
-OAUTH2_SCOPE = (
- "profile", "group", "role", "resource", "user", "masquerade",
- "introspect")
+OAUTH2_SCOPES_SUPPORTED = (
+ # Used by Authlib's `authlib.integrations.flask_oauth2.AuthorizationServer`
+ # class to setup the supported scopes.
+ "profile", "group", "role", "resource", "register-client", "user",
+ "masquerade", "introspect", "migrate-data")
CORS_ORIGINS = "*"
CORS_HEADERS = [
@@ -29,9 +34,9 @@ CORS_HEADERS = [
"Access-Control-Allow-Credentials"
]
-# OpenSSL keys
-CLIENTS_SSL_PUBLIC_KEYS_DIR = "" # clients' public keys' directory
-SSL_PRIVATE_KEY = "" # authorisation server primary key
+# JSON Web Keys (JWKs)
+JWKS_ROTATION_AGE_DAYS = 7 # Days (from creation) to keep a JWK in use.
+JWKS_DELETION_AGE_DAYS = 14 # Days (from creation) to keep a JWK around before deleting it.
## Email
SMTP_HOST = "smtp.genenetwork.org" # does not actually exist right now
@@ -39,3 +44,8 @@ SMTP_PORT = 587
SMTP_TIMEOUT = 200 # seconds
SMTP_USER = "no-reply@genenetwork.org"
SMTP_PASSWORD = "asecrettoken"
+EMAIL_ADDRESS = "no-reply@uthsc.edu"
+
+
+## Variable settings for various emails going out to users
+AUTH_EMAILS_EXPIRY_MINUTES = 15
diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py
index 0c9f878..0040f35 100644
--- a/gn_auth/smtp.py
+++ b/gn_auth/smtp.py
@@ -16,18 +16,18 @@ def __read_mime__(filepath) -> dict:
return {}
-def build_email_message(# pylint: disable=[too-many-arguments]
+def build_email_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ from_address: str,
to_addresses: tuple[Address, ...],
subject: str,
txtmessage: str,
htmlmessage: str = "",
- attachments: tuple[str, ...] = tuple(),
- from_address: Address = Address(
- "GeneNetwork Automated Emails", "no-reply", "genenetwork.org")
+ attachments: tuple[str, ...] = tuple()
) -> EmailMessage:
"""Build an email message."""
msg = EmailMessage()
- msg["From"] = from_address
+ msg["From"] = Address(display_name="GeneNetwork Automated Emails",
+ addr_spec=from_address)
msg["To"] = to_addresses
msg["Subject"] = subject
msg.set_content(txtmessage)
@@ -40,7 +40,7 @@ def build_email_message(# pylint: disable=[too-many-arguments]
return msg
-def send_message(# pylint: disable=[too-many-arguments]
+def send_message(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
smtp_user: str,
smtp_passwd: str,
message: EmailMessage,
@@ -53,6 +53,9 @@ def send_message(# pylint: disable=[too-many-arguments]
"""Set up a connection to a SMTP server and send a message."""
logging.debug("Email to send:\n******\n%s\n******\n", message.as_string())
with smtplib.SMTP(host, port, local_hostname, timeout, source_address) as conn:
- conn.starttls()
- conn.login(smtp_user, smtp_passwd)
+ conn.ehlo()
+ if bool(smtp_user) and bool(smtp_passwd):
+ conn.starttls()
+ conn.login(smtp_user, smtp_passwd)
+
conn.send_message(message)
diff --git a/gn_auth/static/images/CITGLogo.png b/gn_auth/static/images/CITGLogo.png
new file mode 100644
index 0000000..ae99fed
--- /dev/null
+++ b/gn_auth/static/images/CITGLogo.png
Binary files differ
diff --git a/gn_auth/templates/50x.html b/gn_auth/templates/50x.html
new file mode 100644
index 0000000..859a232
--- /dev/null
+++ b/gn_auth/templates/50x.html
@@ -0,0 +1,25 @@
+{%extends "base.html"%}
+
+{%block title%}
+{{error.status_code or 500}}: {{error.description or "Generic Exception"}}
+{%endblock%}
+
+{%block pagetitle%}
+{{error.status_code or 500}}: {{error.description or "Generic Exception"}}
+{%endblock%}
+
+{%block content%}
+
+<p>The system has done something it should not! This is our fault.</p>
+
+<p>
+ Please send us the details below to help us figure out what we did wrong and
+ fix it.
+</p>
+
+<div class="row">
+ <p><strong>URI: {{page}}</p>
+ <pre>{{"".join(trace)}}</pre>
+</div>
+
+{%endblock%}
diff --git a/gn_auth/templates/admin/confirm-change-client-secret.html b/gn_auth/templates/admin/confirm-change-client-secret.html
new file mode 100644
index 0000000..aa8ef81
--- /dev/null
+++ b/gn_auth/templates/admin/confirm-change-client-secret.html
@@ -0,0 +1,45 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: View OAuth2 Client{%endblock%}
+
+{%block pagetitle%}View OAuth2 Client{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<h2>Change Oauth2 Client Secret</h2>
+
+<p>You are attempting to change the <strong>CLIENT_SECRET</strong> value for the
+ following client:</p>
+
+<table class="table">
+ <tbody>
+ <tr>
+ <td><strong>Client ID</strong></td>
+ <td>{{client.client_id}}</td>
+ </tr>
+ <tr>
+ <td><strong>Client Name</strong></td>
+ <td>{{client.client_metadata.client_name}}</td>
+ </tr>
+ </tbody>
+</table>
+
+<p>Are you absolutely sure you want to do this?<br />
+ <small>Note that you'll need to update your configurations for the client and
+ restart it for the settings to take effect!</small></p>
+
+<form id="frm-change-client-secret"
+ method="POST"
+ action="{{url_for('oauth2.admin.change_client_secret',
+ client_id=client.client_id)}}">
+
+ <input type="hidden" name="client_id" value="{{client.client_id}}" />
+ <input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" />
+
+ <div class="form-group">
+ <input type="submit" class="btn btn-danger" value="generate new secret" />
+ </div>
+</form>
+
+{%endblock%}
diff --git a/gn_auth/templates/admin/list-oauth2-clients.html b/gn_auth/templates/admin/list-oauth2-clients.html
index ca0ee6d..6da5b2f 100644
--- a/gn_auth/templates/admin/list-oauth2-clients.html
+++ b/gn_auth/templates/admin/list-oauth2-clients.html
@@ -15,7 +15,7 @@
<th>Client Name</th>
<th>Default Redirect URI</th>
<th>Owner</th>
- <th colspan="2">Actions</th>
+ <th colspan="3">Actions</th>
</tr>
</thead>
@@ -43,6 +43,14 @@
class="btn btn-danger" />
</form>
</td>
+ <td>
+ <a href="{{url_for('oauth2.admin.change_client_secret',
+ client_id=client.client_id)}}"
+ title="Change the client secret!"
+ class="btn btn-danger">
+ Change Secret
+ </a>
+ </td>
</tr>
{%else%}
<tr>
diff --git a/gn_auth/templates/admin/register-client.html b/gn_auth/templates/admin/register-client.html
index 20d7aa2..bfe56f8 100644
--- a/gn_auth/templates/admin/register-client.html
+++ b/gn_auth/templates/admin/register-client.html
@@ -9,59 +9,72 @@
<form method="POST" action="{{url_for('oauth2.admin.register_client')}}">
- <fieldset>
- <legend>Select client scope</legend>
-
+ <legend>Select client scope</legend>
+ <div class="form-group">
{%for scp in scope%}
- <input name="scope[]" id="chk-{{scp}}"type="checkbox" value="{{scp}}"
- {%if scp=="profile"%}checked="checked"{%endif%} />
- <label for="chk-{{scp}}">{{scp}}</label><br />
+ <div class="checkbox">
+ <label for="chk-{{scp}}">
+ <input name="scope[]" id="chk-{{scp}}"type="checkbox" value="{{scp}}"
+ {%if scp=="profile"%}checked="checked"{%endif%} />
+ {{scp}}
+ </label>
+ </div>
{%endfor%}
+ </div>
- </fieldset>
-
- <fieldset>
- <legend>Basic OAuth2 client information</legend>
-
-
- <label for="txt-client-name">Client name</label>
- <input name="client_name" type="text" id="txt-client-name"
+ <legend>Basic OAuth2 client information</legend>
+ <div class="form-group">
+ <label for="txt-client-name" class="form-label">Client name</label>
+ <input name="client_name"
+ type="text"
+ id="txt-client-name"
+ class="form-control"
required="required" />
- <br /><br />
+ </div>
- <label for="txt-redirect-uri">Redirect URI</label>
- <input name="redirect_uri" type="text" id="txt-redirect-uri"
+ <div class="form-group">
+ <label for="txt-redirect-uri" class="form-label">Redirect URI</label>
+ <input name="redirect_uri"
+ type="text"
+ id="txt-redirect-uri"
+ class="form-control"
required="required" />
- <br /><br />
+ </div>
- <label for="txt-other-redirect-uris">
- Other redirect URIs (Enter one URI per line)</label>
- <br />
- <textarea name="other_redirect_uris" id="txt-other-redirect-uris"
+ <div class="form-group">
+ <label for="txt-other-redirect-uris" class="form-label">
+ Other redirect URIs</label>
+ <div class="form-text text-muted">Enter one URI per line</div>
+ <textarea name="other_redirect_uris"
+ id="txt-other-redirect-uris"
cols="80" rows="10"
+ class="form-control"
title="Enter one URI per line."></textarea>
- <br /><br />
- <fieldset>
- <legend>Supported grant types</legend>
- {%for granttype in granttypes%}
- <input name="grants[]"
- type="checkbox"
- value="{{granttype.value}}"
- id="chk-{{granttype.name.lower().replace(' ', '-')}}"
- checked="checked" />
+ </div>
+
+ <div class="form-group">
+ <legend>Supported grant types</legend>
+ {%for granttype in granttypes%}
+ <div class="checkbox">
<label for="chk-{{granttype.name.lower().replace(' ', '-')}}">
+ <input name="grants[]"
+ type="checkbox"
+ value="{{granttype.value}}"
+ id="chk-{{granttype.name.lower().replace(' ', '-')}}"
+ checked="checked" />
{{granttype.name}}
</label>
- <br /><br />
- {%endfor%}
- </fieldset>
- </fieldset>
-
- <fieldset>
- <legend>User information</legend>
+ </div>
+ {%endfor%}
+ </div>
- <p>The user to register this client for</p>
- <select name="user" required="required">
+ <legend>User information</legend>
+ <div class="form-group">
+ <label for="select-user">The user to register this client for</label>
+ <select id="select-user"
+ name="user"
+ class="form-control"
+ required="required">
{%for user in users%}
<option value="{{user.user_id}}"
{%if user.user_id==current_user.user_id%}
@@ -69,8 +82,18 @@
{%endif%}>{{user.name}} ({{user.email}})</option>
{%endfor%}
</select>
- </fieldset>
-
- <input type="submit" value="register client" />
+ </div>
+
+ <legend>Other metadata</legend>
+ <div class="form-group">
+ <label class="form-group" for="txt-client-jwk-uri">
+ Client's Public JWKs</label>
+ <input type="text"
+ id="txt-client-jwk-uri"
+ name="client_jwk_uri"
+ class="form-control" />
+ </div>
+
+ <input type="submit" value="register client" class="btn btn-primary" />
</form>
{%endblock%}
diff --git a/gn_auth/templates/admin/view-oauth2-client.html b/gn_auth/templates/admin/view-oauth2-client.html
index 415873d..c250ee3 100644
--- a/gn_auth/templates/admin/view-oauth2-client.html
+++ b/gn_auth/templates/admin/view-oauth2-client.html
@@ -13,118 +13,82 @@
{%set client = client.value%}
<form method="POST" action="{{url_for('oauth2.admin.edit_client')}}">
<legend>View/Edit Oauth2 Client</legend>
+
<input type="hidden" name="client_id" value="{{client.client_id}}" />
<input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" />
+
<div>
- <p><strong>Client ID: <strong> {{client.client_id}}</p>
- <p><strong>Client Name: <strong> {{client.client_metadata.client_name}}</p>
+ <p><strong>Client ID: </strong> {{client.client_id}}</p>
+ <p><strong>Client Name: </strong> {{client.client_metadata.client_name}}</p>
</div>
- <fieldset>
+
+ <div class="form-group">
<legend>Scope</legend>
{%for scp in scope%}
- <input name="scope[]" id="chk:{{scp}}" type="checkbox" value="{{scp}}"
- {%if scp in client.client_metadata.scope%}
- checked="checked"
- {%endif%} />
- <label for="chk:{{scp}}">{{scp}}</label><br />
+ <div class="checkbox">
+ <label for="chk:{{scp}}">
+ <input name="scope[]" id="chk:{{scp}}" type="checkbox" value="{{scp}}"
+ {%if scp in client.client_metadata.scope%}
+ checked="checked"
+ {%endif%} />
+ {{scp}}</label><br />
+ </div>
{%endfor%}
- </fieldset>
+ </div>
- <fieldset>
+ <div class="form-group">
<legend>Redirect URIs</legend>
- <label for="txt-redirect-uri">Default Redirect URI</label>
+ <label for="txt-redirect-uri" class="form-label">Default Redirect URI</label>
<br />
- <input type="text" name="redirect_uri" id="txt-redirect-uri"
+ <input type="text"
+ name="redirect_uri"
+ id="txt-redirect-uri"
value="{{client.client_metadata.default_redirect_uri}}"
required="required"
class="form-control" />
- <br /><br />
+ </div>
- <label for="txta:other-redirect-uris">Other Redirect URIs</label>
- <br />
+ <div class="form-group">
+ <label for="txta:other-redirect-uris"
+ class="form-label">Other Redirect URIs</label>
<textarea id="txta:other-redirect-uris"
name="other_redirect_uris"
cols="80" rows="10"
+ class="form-control"
title="Enter one URI per line."
>{{"\r\n".join(client.client_metadata.redirect_uris)}}</textarea>
- </fieldset>
+ </div>
- <fieldset>
+ <div class="form-group">
<legend>Grants</legend>
- {%for granttype in granttypes%}
- <input name="grants[]"
- type="checkbox"
- value="{{granttype.value}}"
- id="chk-{{granttype.name.lower().replace(' ', '-')}}"
- {%if granttype.value in client.client_metadata.grant_types%}
- checked="checked"
- {%endif%} />
+ {%for granttype in granttypes%}
+ <div class="checkbox">
<label for="chk-{{granttype.name.lower().replace(' ', '-')}}">
+ <input name="grants[]"
+ type="checkbox"
+ value="{{granttype.value}}"
+ id="chk-{{granttype.name.lower().replace(' ', '-')}}"
+ {%if granttype.value in client.client_metadata.grant_types%}
+ checked="checked"
+ {%endif%} />
{{granttype.name}}
</label>
- <br /><br />
- {%endfor%}
- </fieldset>
-
- <input type="submit" class="btn btn-primary" value="update client" />
-</form>
-
-<hr />
-<h2>Signing/Verification SSL Keys</h2>
-<table>
- <caption>Registered Public Keys</caption>
- <thead>
- <tr>
- <th>JWK Thumbprint</th>
- <th>Actions</th>
- </tr>
- </thead>
-
- <tbody>
- {%for sslkey in client.jwks.keys:%}
- <tr>
- <td>{{sslkey.thumbprint()}}</td>
- <td>
- <form method="POST"
- action="{{url_for('oauth2.admin.delete_client_public_key')}}">
- <input type="hidden"
- name="client_id"
- value="{{client.client_id}}" />
- <input type="hidden"
- name="ssl_key"
- value="{{sslkey.thumbprint()}}" />
- <input type="submit"
- class="btn btn-danger"
- value="delete key" />
- </form>
- </td>
- </tr>
- {%else%}
- <tr>
- <td class="alert-warning"
- colspan="2">
- There are no registered SSL keys for this client.
- </td>
- </tr>
+ </div>
{%endfor%}
- </tbody>
-</table>
-<form id="frm-client-add-ssl-key"
- method="POST"
- action="{{url_for('oauth2.admin.register_client_public_key')}}">
- <legend>Register new SSL key</legend>
- <input type="hidden" name="client_id" value="{{client.client_id}}" />
- <fieldset>
- <label for="txt-area-client-ssl-key">Client's Public Key</label>
- <textarea id="txt-area-client-ssl-key"
- name="client_ssl_key"
- required="required"
- class="form-control"
- rows="10"></textarea>
- </fieldset>
+ </div>
+
+ <legend>Other metadata</legend>
+ <div class="form-group">
+ <label class="form-group" for="txt-client-jwk-uri">
+ Client's Public JWKs</label>
+ <input type="text"
+ id="txt-client-jwk-uri"
+ name="client_jwk_uri"
+ class="form-control"
+ value="{{client.client_metadata.get('public-jwks-uri', '')}}" />
+ </div>
- <br />
- <input type="submit" class="btn btn-primary" value="register key" />
+ <input type="submit" class="btn btn-primary" value="update client" />
</form>
{%endif%}
diff --git a/gn_auth/templates/base.html b/gn_auth/templates/base.html
index b452ca1..c90ac9b 100644
--- a/gn_auth/templates/base.html
+++ b/gn_auth/templates/base.html
@@ -5,7 +5,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>gn-auth: {%block title%}{%endblock%}</title>
+ <title>Authorization {%block title%}{%endblock%}</title>
<link rel="stylesheet" type="text/css"
href="https://genenetwork.org/static/new/css/bootstrap-custom.css" />
@@ -39,7 +39,7 @@
style="font-weight: bold;">GeneNetwork</a>
</li>
<li>
- <a href="#">gn-auth: {%block pagetitle%}{%endblock%}</a>
+ <a href="#">{%block pagetitle%}{%endblock%}</a>
</li>
</ul>
</div>
diff --git a/gn_auth/templates/emails/forgot-password.html b/gn_auth/templates/emails/forgot-password.html
new file mode 100644
index 0000000..5f16a02
--- /dev/null
+++ b/gn_auth/templates/emails/forgot-password.html
@@ -0,0 +1,38 @@
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>{{subject}}</title>
+ </head>
+ <body>
+ <p>
+ You (or someone pretending to be you) made a request to change your
+ password. Please follow the link below to change it.
+ </p>
+
+ <p>
+ Click the button below to change your password
+ <a href="{{forgot_password_uri}}"
+ style="display: block;text-align: center;vertical-align: center;cursor: pointer;border-radius: 4px;background-color: #336699;border-color: #357ebd;color: white;text-decoration: none;font-size: large;width: 9em;text-transform: capitalize;margin: 1em 0 0 3em;box-shadow: 2px 2px rgba(0, 0, 0, 0.3);">Change my Password</a>.</p>
+
+ <p>
+ Or copy the link below onto your browser's address bar:<br /><br />
+ <span style="font-weight: bolder;">{{forgot_password_uri}}</span>
+ </p>
+
+ <p>
+ If you did not request to change your password, simply ignore this email.
+ </p>
+
+ <p style="font-weight: bold;color: #ee55ee;">
+ The link will expire in <strong>{{expiration_minutes}}</strong>.
+ </p>
+
+ <hr />
+ <p>
+ <small>
+ Note that if you requested to change your password multiple times, only
+ the latest/newest token will be valid.
+ </small>
+ </p>
+ </body>
+</html>
diff --git a/gn_auth/templates/emails/forgot-password.txt b/gn_auth/templates/emails/forgot-password.txt
new file mode 100644
index 0000000..68abf16
--- /dev/null
+++ b/gn_auth/templates/emails/forgot-password.txt
@@ -0,0 +1,12 @@
+{{subject}}
+===============
+
+You (or someone pretending to be you) made a request to change your password. Please copy the link below onto your browser to change your password:
+
+{{forgot_password_uri}}
+
+If you did not request to change your password, simply ignore this email.
+
+The link will expire in {{expiration_minutes}}.
+
+Note that if you requested to change your password multiple times, only the latest/newest token will be valid.
diff --git a/gn_auth/templates/emails/verify-email.html b/gn_auth/templates/emails/verify-email.html
index 7f85c1c..11ae575 100644
--- a/gn_auth/templates/emails/verify-email.html
+++ b/gn_auth/templates/emails/verify-email.html
@@ -20,7 +20,7 @@
<p style="font-weight: bold;color: #ee55ee;">
Please note that the verification code will expire in
- <strong>{{expiration_minutes}}</strong> minutes after it was generated.
+ <strong>{{expiration_minutes}}</strong> after it was generated.
</p>
</body>
</html>
diff --git a/gn_auth/templates/emails/verify-email.txt b/gn_auth/templates/emails/verify-email.txt
index 281d682..ecfbfc0 100644
--- a/gn_auth/templates/emails/verify-email.txt
+++ b/gn_auth/templates/emails/verify-email.txt
@@ -9,4 +9,4 @@ If that does not work, please log in to GeneNetwork and copy the verification co
{{verification_code}}
-Please note that the verification code will expire {{expiration_minutes}} minutes after it was generated.
+Please note that the verification code will expire {{expiration_minutes}} after it was generated.
diff --git a/gn_auth/templates/oauth2/authorise-user.html b/gn_auth/templates/oauth2/authorise-user.html
index deac40a..f186167 100644
--- a/gn_auth/templates/oauth2/authorise-user.html
+++ b/gn_auth/templates/oauth2/authorise-user.html
@@ -2,36 +2,65 @@
{%block title%}Authorise User{%endblock%}
-{%block pagetitle%}Authenticate to the API Server{%endblock%}
+{%block pagetitle%}{%endblock%}
{%block content%}
{{flash_messages()}}
+<div class="container" style="min-width: 1250px;">
+ <form method="POST"
+ class="form-horizontal"
+ action="{{url_for(
+ 'oauth2.auth.authorise',
+ response_type=response_type,
+ client_id=client.client_id,
+ redirect_uri=redirect_uri)}}"
+ style="max-width: 700px;">
+ <legend style="margin-top: 20px;">Sign In</legend>
-<form method="POST" action="{{url_for(
- 'oauth2.auth.authorise',
- response_type=response_type,
- client_id=client.client_id,
- redirect_uri=redirect_uri)}}">
- <input type="hidden" name="response_type" value="{{response_type}}" />
- <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
- <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
- <input type="hidden" name="client_id" value="{{client.client_id}}" />
+ <input type="hidden" name="response_type" value="{{response_type}}" />
+ <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
+ <input type="hidden" name="scope" value="{{scope | join(' ')}}" />
+ <input type="hidden" name="client_id" value="{{client.client_id}}" />
- <fieldset>
- <legend>User Credentials</legend>
- <fieldset class="form-group">
- <label for="user:email" class="form-label">Email</label>
- <input type="email" name="user:email" id="user:email" required="required"
- class="form-control"/>
- </fieldset>
+ <div class="form-group">
+ <label for="user:email" class="control-label col-xs-2"
+ style="text-align: left;">Email</label>
+ <div class="col-xs-10">
+ <input type="email"
+ name="user:email"
+ id="user:email"
+ required="required"
+ class="form-control" />
+ </div>
+ </div>
- <fieldset class="form-group">
- <label for="user:password" class="form-label">Password</label>
- <input type="password" name="user:password" id="user:password"
- required="required" class="form-control" />
- </fieldset>
- </fieldset>
-
- <input type="submit" value="authorise" class="btn btn-primary" />
-</form>
+ <div class="form-group">
+ <label for="user:password" class="control-label col-xs-2"
+ style="text-align: left;">Password</label>
+ <div class="col-xs-10">
+ <input type="password"
+ name="user:password"
+ id="user:password"
+ required="required"
+ class="form-control" />
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="controls col-xs-offset-2 col-xs-10">
+ <input type="submit" value="Sign in" class="btn btn-primary" />
+ {%if display_forgot_password%}
+ <a href="{{url_for('oauth2.users.forgot_password',
+ client_id=client.client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)}}"
+ title="Click here to change your password."
+ class="form-text text-danger">Forgot Password</a>
+ {%endif%}
+ </div>
+ </div>
+ <hr>
+ <a href="{{ source_uri }}/oauth2/user/register" class="btn btn-primary" role="button">Create a New Account</a>
+ </form>
+</div>
{%endblock%}
diff --git a/gn_auth/templates/users/change-password.html b/gn_auth/templates/users/change-password.html
new file mode 100644
index 0000000..f328255
--- /dev/null
+++ b/gn_auth/templates/users/change-password.html
@@ -0,0 +1,52 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: Change Password{%endblock%}
+
+{%block pagetitle%}Change Password{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<div class="container-fluid">
+ <div class="row"><h1>Change Password</h1></div>
+
+ <div class="row">
+ <form method="POST"
+ action="{{url_for('oauth2.users.change_password',
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type,
+ forgot_password_token=forgot_password_token)}}">
+ <div class="form-group">
+ <p class="form-text text-info">
+ Change the password for your account with the email
+ "<strong>{{email}}</strong>".
+ </p>
+ </div>
+
+ <div class="form-group">
+ <label for="txt-password" class="form-label">New Password</label>
+ <input type="password"
+ id="txt-password"
+ name="password"
+ class="form-control"
+ required="required" />
+ </div>
+
+ <div class="form-group">
+ <label for="txt-confirm" class="form-label">Confirm New Password</label>
+ <input type="password"
+ id="txt-confirm"
+ name="confirm-password"
+ class="form-control"
+ required="required" />
+ </div>
+
+ <div class="form-group">
+ <input type="submit" class="btn btn-danger" value="change password" />
+ </div>
+ </form>
+ </div>
+
+</div>
+{%endblock%}
diff --git a/gn_auth/templates/users/forgot-password-token-send-success.html b/gn_auth/templates/users/forgot-password-token-send-success.html
new file mode 100644
index 0000000..8782e8c
--- /dev/null
+++ b/gn_auth/templates/users/forgot-password-token-send-success.html
@@ -0,0 +1,22 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: Forgot Password{%endblock%}
+
+{%block pagetitle%}Forgot Password{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<div class="container-fluid">
+ <div class="row"><h1>Forgot Password</h1></div>
+
+ <div class="row">
+ <p class="text-info"
+ style="font-size:1.5em;text-align:center;margin-top:2em;">
+ We have sent an email to '<strong>{{email}}</strong>'. Please use the link
+ in the email we sent to change your password.
+ </p>
+ </div>
+
+</div>
+{%endblock%}
diff --git a/gn_auth/templates/users/forgot-password.html b/gn_auth/templates/users/forgot-password.html
new file mode 100644
index 0000000..0455c69
--- /dev/null
+++ b/gn_auth/templates/users/forgot-password.html
@@ -0,0 +1,38 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: Forgot Password{%endblock%}
+
+{%block pagetitle%}Forgot Password{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<div class="container-fluid">
+ <div class="row"><h1>Forgot Password</h1></div>
+
+ <div class="row">
+ <form method="POST"
+ action="{{url_for('oauth2.users.forgot_password',
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)}}">
+ <div class="form-group">
+ <span>
+ Provide you email below, and we will send you a link you can use to
+ change your password.
+ </span>
+ </div>
+
+ <div class="form-group">
+ <label for="txt-email" class="form-label">Email</label>
+ <input type="email" name="email" id="txt-email" class="form-control" />
+ </div>
+
+ <div class="form-group">
+ <input type="submit" class="btn btn-primary" value="Send Link" />
+ </div>
+ </form>
+ </div>
+
+</div>
+{%endblock%}
diff --git a/gn_auth/templates/users/unverified-user.html b/gn_auth/templates/users/unverified-user.html
index 0ce141d..fcd34ad 100644
--- a/gn_auth/templates/users/unverified-user.html
+++ b/gn_auth/templates/users/unverified-user.html
@@ -7,69 +7,87 @@
{%block content%}
{{flash_messages()}}
-<h1>Verify Your E-Mail</h1>
-
-<form id="frm-email-verification" method="POST"
- action="{{url_for('oauth2.users.verify_user')}}">
- <legend>Email Verification</legend>
-
- <p>In order to reduce the number of bots we have to deal with, we no longer
- allow sign-in with users who have not verified their accounts.</p>
-
- <p>We know this is annoying &mdash; especially if you already have an account,
- and have been using it just fine &mdash; however, we have found that without
- this check in place, we will get overrun by silly bots, which will ruin
- every user's experience.</p>
-
- <p>
- Do bear with us, enter the verification code you received via email below:
- </p>
-
- <input type="hidden" name="email" value="{{email}}" />
- <input type="hidden" name="response_type" value="{{response_type}}" />
- <input type="hidden" name="client_id" value="{{client_id}}" />
- <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
-
- <fieldset class="form-group">
- <label for="txt-verification-code" class="form-label">
- Verification Code</label>
- <input id="txt-verification-code" name="verificationcode" type="text"
- required="required" class="form-control"
- placeholder="Enter your verification code here." />
- </fieldset>
-
- <fieldset>
- <input type="submit" value="Verify Email Address" class="btn btn-primary" />
- </fieldset>
-</form>
-
-<h2>Send Verification Code</h2>
-
-<form id="frm-send-verification-code" method="POST"
- action="{{url_for('oauth2.users.send_verification_code')}}">
- <legend>Send Verification Code</legend>
-
- <p>If you have not received a verification code, or your code is already
- expired, provide <strong>your GeneNetwork</strong> password and
- click the "<em>Send Verification Code</em>" button below and we will send
- you a new verification code.</p>
-
- <input type="hidden" name="user_email" value="{{email}}" />
- <input type="hidden" name="response_type" value="{{response_type}}" />
- <input type="hidden" name="client_id" value="{{client_id}}" />
- <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
-
- <fieldset class="form-group">
- <label class="form-label">Email</label>
- <label class="form-control">{{email}}</label>
- </fieldset>
-
- <fieldset class="form-group">
- <label for="txt-password" class="form-label">Password</label>
- <input id="txt-password" name="user_password" type="password"
- placeholder="Enter your GeneNetwork password"
- class="form-control" />
- </fieldset>
- <input type="submit" value="Send Verification Code" class="btn btn-danger" />
-</form>
+<div class="container-fluid">
+ <div class="row"><h1>Verify Your E-Mail</h1></div>
+
+ {%if token_found:%}
+ <div class="row">
+ <form id="frm-email-verification" method="POST"
+ action="{{url_for('oauth2.users.verify_user')}}">
+ <legend>Email Verification</legend>
+
+ <p>If you are seeing this, your account needs to be verified.</p>
+
+ <p>An email with a verification token has already been sent to the address
+ associated with this account (<em>{{email}}</em>). Please provide that
+ verification token below and click the "<em>Verify Email Address</em>"
+ button to verify your account.</p>
+
+ <input type="hidden" name="email" value="{{email}}" />
+ <input type="hidden" name="response_type" value="{{response_type}}" />
+ <input type="hidden" name="client_id" value="{{client_id}}" />
+ <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
+
+ <fieldset class="form-group">
+ <label for="txt-verification-code" class="form-label">
+ Verification Code</label>
+ <input id="txt-verification-code" name="verificationcode" type="text"
+ required="required" class="form-control"
+ placeholder="Enter your verification code here." />
+ </fieldset>
+
+ <fieldset>
+ <input type="submit" value="Verify Email Address" class="btn btn-primary" />
+ </fieldset>
+ </form>
+ </div>
+ {%else:%}
+ <div class="row">
+ <form id="frm-send-verification-code" method="POST"
+ action="{{url_for('oauth2.users.send_verification_code')}}">
+ <legend>Send Verification Code</legend>
+
+ <p>Provide your password below, and we will send you a verification password
+ to your email.</p>
+ <p>You are seeing this page because:</p>
+ <ol type="a">
+ <li>You already had an existing account.<br />
+ In this case, you will need to request a verification code by
+ providing your email below and clicking the
+ "<em>Send Verification Code</em>" button.<br />
+ We will send you an email with both:
+ <ol type="1">
+ <li>a link you can click to verify your email, <strong>and</strong>
+ </li>
+ <li>a token to copy and paste if you choose not to follow the link.
+ </li>
+ </ol>
+ </li>
+ <li>You registered your account recently, but did not verify it within the
+ time period allocated for that. In this case, simply request a new
+ verification email below, and follow the link, or copy and paste the
+ token in the email we send you.</li>
+ </ol>
+
+ <input type="hidden" name="user_email" value="{{email}}" />
+ <input type="hidden" name="response_type" value="{{response_type}}" />
+ <input type="hidden" name="client_id" value="{{client_id}}" />
+ <input type="hidden" name="redirect_uri" value="{{redirect_uri}}" />
+
+ <fieldset class="form-group">
+ <label class="form-label">Email</label>
+ <label class="form-control">{{email}}</label>
+ </fieldset>
+
+ <fieldset class="form-group">
+ <label for="txt-password" class="form-label">Password</label>
+ <input id="txt-password" name="user_password" type="password"
+ placeholder="Enter your GeneNetwork password"
+ class="form-control" />
+ </fieldset>
+ <input type="submit" value="Send Verification Code" class="btn btn-danger" />
+ </form>
+ </div>
+ {%endif%}
+</div>
{%endblock%}
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index 811a0d5..e05ef0d 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -1,16 +1,12 @@
"""Main entry point for project"""
-import os
import sys
import uuid
import json
-import logging
from math import ceil
from pathlib import Path
-from typing import Callable
from datetime import datetime
import click
-from flask import Flask
from yoyo import get_backend, read_migrations
from gn_auth import migrations
@@ -22,39 +18,9 @@ from gn_auth.auth.authentication.users import user_by_id, hash_password
from gn_auth.auth.authorisation.users.admin.models import make_sys_admin
from scripts import register_sys_admin as rsysadm# type: ignore[import]
-from scripts import migrate_existing_data as med# type: ignore[import]
-def dev_loggers(appl: Flask) -> None:
- """Setup the logging handlers."""
- stderr_handler = logging.StreamHandler(stream=sys.stderr)
- appl.logger.addHandler(stderr_handler)
-
- root_logger = logging.getLogger()
- root_logger.addHandler(stderr_handler)
- root_logger.setLevel(appl.config["LOGLEVEL"])
-
-
-def gunicorn_loggers(appl: Flask) -> None:
- """Use gunicorn logging handlers for the application."""
- logger = logging.getLogger("gunicorn.error")
- appl.logger.handlers = logger.handlers
- appl.logger.setLevel(logger.level)
-
-
-def setup_loggers() -> Callable[[Flask], None]:
- """
- Setup the loggers according to the WSGI server used to run the application.
- """
- # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
- # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
- # https://peps.python.org/pep-3333/#id4
- software, *_version_and_comments = os.environ.get(
- "SERVER_SOFTWARE", "").split('/')
- return gunicorn_loggers if bool(software) else dev_loggers
-
-# app = create_app()
-app = create_app(setup_logging=setup_loggers())
+app = create_app()
##### BEGIN: CLI Commands #####
@@ -67,8 +33,14 @@ def apply_migrations():
def __init_dev_users__():
"""Initialise dev users. Get's used in more than one place"""
- dev_users_query = "INSERT INTO users VALUES (:user_id, :email, :name)"
- dev_users_passwd = "INSERT INTO user_credentials VALUES (:user_id, :hash)"
+ dev_users_query = """
+ INSERT INTO users (user_id, email, name, verified)
+ VALUES (:user_id, :email, :name, 1)
+ ON CONFLICT(email) DO UPDATE SET
+ name=excluded.name,
+ verified=excluded.verified
+ """
+ dev_users_passwd = "INSERT OR REPLACE INTO user_credentials VALUES (:user_id, :hash)"
dev_users = ({
"user_id": "0ad1917c-57da-46dc-b79e-c81c91e5b928",
"email": "test@development.user",
@@ -91,18 +63,26 @@ def init_dev_users():
__init_dev_users__()
@app.cli.command()
-def init_dev_clients():
+@click.option('--client-uri', default= "http://localhost:5033", type=str)
+def init_dev_clients(client_uri):
"""
Initialise a development client for OAuth2 sessions.
**NOTE**: You really should not run this in production/staging
"""
+ client_uri = client_uri.lstrip("/")
__init_dev_users__()
- dev_clients_query = (
- "INSERT INTO oauth2_clients VALUES ("
- ":client_id, :client_secret, :client_id_issued_at, "
- ":client_secret_expires_at, :client_metadata, :user_id"
- ")")
+ dev_clients_query = """
+ INSERT INTO oauth2_clients VALUES (
+ :client_id, :client_secret, :client_id_issued_at,
+ :client_secret_expires_at, :client_metadata, :user_id
+ )
+ ON CONFLICT(client_id) DO UPDATE SET
+ client_secret=excluded.client_secret,
+ client_secret_expires_at=excluded.client_secret_expires_at,
+ client_metadata=excluded.client_metadata,
+ user_id=excluded.user_id
+ """
dev_clients = ({
"client_id": "0bbfca82-d73f-4bd4-a140-5ae7abb4a64d",
"client_secret": "yadabadaboo",
@@ -113,10 +93,12 @@ def init_dev_clients():
"token_endpoint_auth_method": [
"client_secret_post", "client_secret_basic"],
"client_type": "confidential",
- "grant_types": ["password", "authorization_code", "refresh_token"],
- "default_redirect_uri": "http://localhost:5033/oauth2/code",
- "redirect_uris": ["http://localhost:5033/oauth2/code",
- "http://localhost:5033/oauth2/token"],
+ "grant_types": ["password", "authorization_code", "refresh_token",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer"],
+ "default_redirect_uri": f"{client_uri}/oauth2/code",
+ "redirect_uris": [f"{client_uri}/oauth2/code",
+ f"{client_uri}/oauth2/token"],
+ "public-jwks-uri": f"{client_uri}/oauth2/public-jwks",
"response_type": ["code", "token"],
"scope": ["profile", "group", "role", "resource", "register-client",
"user", "masquerade", "migrate-data", "introspect"]
@@ -141,11 +123,6 @@ def assign_system_admin(user_id: uuid.UUID):
sys.exit(1)
@app.cli.command()
-def make_data_public():
- """Make existing data that is not assigned to any group publicly visible."""
- med.entry(app.config["AUTH_DB"], app.config["SQL_URI"])
-
-@app.cli.command()
def register_admin():
"""Register the administrator."""
rsysadm.register_admin(Path(app.config["AUTH_DB"]))
diff --git a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
new file mode 100644
index 0000000..44318bd
--- /dev/null
+++ b/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
@@ -0,0 +1,26 @@
+"""
+Create forgot_password_tokens table
+
+This will be used to enable users to validate/verify their password change
+requests.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240606_03_BY7Us-drop-group-roles-table'}
+
+steps = [
+ step(
+ """
+ CREATE TABLE IF NOT EXISTS forgot_password_tokens(
+ user_id TEXT NOT NULL,
+ token TEXT NOT NULL,
+ generated INTEGER NOT NULL,
+ expires INTEGER NOT NULL,
+ PRIMARY KEY(user_id),
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
+ ON UPDATE CASCADE ON DELETE CASCADE
+ ) WITHOUT ROWID
+ """,
+ "DROP TABLE IF EXISTS forgot_password_tokens")
+]
diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
new file mode 100644
index 0000000..5c6e81d
--- /dev/null
+++ b/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
@@ -0,0 +1,24 @@
+"""
+hooks_for_edu_domains
+"""
+
+from yoyo import step
+
+__depends__ = {'20240819_01_p2vXR-create-forgot-password-tokens-table'}
+
+steps = [
+ step(
+ """
+ INSERT INTO roles(role_id, role_name, user_editable) VALUES
+ ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'hook-role-from-edu-domain', '0')
+ """,
+ "DELETE FROM roles WHERE role_name='hook-role-from-edu-domain'"),
+ step(
+ """
+ INSERT INTO role_privileges(role_id, privilege_id) VALUES
+ ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:view-resource'),
+ ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:edit-resource')
+ """,
+ "DELETE FROM role_privileges WHERE role_id='9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d'"
+ )
+]
diff --git a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
new file mode 100644
index 0000000..d22ad01
--- /dev/null
+++ b/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
@@ -0,0 +1,42 @@
+"""
+add admin ui privilege to system-administrator role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+def get_system_admin_id(cursor):
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+def add_admin_ui_privilege(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Create admin-ui privilege
+ cursor.execute(
+ "INSERT INTO privileges (privilege_id, privilege_description) "
+ "VALUES(?, ?)",
+ ("system:user:admin-ui", "View UI elements that should only be visible to system administrators"))
+
+ # Add UI privilege to system-administrator role
+ cursor.execute(
+ "INSERT INTO role_privileges (role_id, privilege_id) "
+ "VALUES(?, ?)",
+ (get_system_admin_id(cursor), "system:user:admin-ui")
+ )
+
+def remove_admin_ui_privilege(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Remove UI privilege from system-administrator role
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE privilege_id='system:user:admin-ui'")
+
+ # Remove UI privilege from privileges table
+ cursor.execute(
+ "DELETE FROM privileges WHERE privilege_id='system:user:admin-ui'")
+
+steps = [
+ step(add_admin_ui_privilege, remove_admin_ui_privilege)
+]
diff --git a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
new file mode 100644
index 0000000..73a4880
--- /dev/null
+++ b/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
@@ -0,0 +1,49 @@
+"""
+Add Batch Edit privileges
+"""
+
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role'}
+
+def add_batch_edit_privilege_and_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Create batch edit privilege
+ cursor.execute(
+ "INSERT INTO privileges (privilege_id, privilege_description) "
+ "VALUES(?, ?)",
+ ("system:data:batch-edit", "Batch Edit"))
+
+ # Create batch editor role
+ cursor.execute(
+ "INSERT INTO roles (role_id, role_name, user_editable) "
+ "VALUES(?, ?, ?)",
+ ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "Batch Editors", 0))
+
+ # Link role/privilege
+ cursor.execute(
+ "INSERT INTO role_privileges (role_id, privilege_id) "
+ "VALUES(?, ?)",
+ ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "system:data:batch-edit")
+ )
+
+def remove_batch_edit_privilege_and_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ # Remove batch edit role/privilege link
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE privilege_id='system:data:batch-edit'")
+
+ # Remove Batch Editor role
+ cursor.execute(
+ "DELETE FROM roles WHERE role_id='0f391910-5225-476a-bb8d-9c0adc9d81cc'")
+
+ # Remove Batch Edit privilege
+ cursor.execute(
+ "DELETE FROM privileges WHERE privilege_id='system:data:batch-edit'")
+
+
+steps = [
+ step(add_batch_edit_privilege_and_role, remove_batch_edit_privilege_and_role)
+]
diff --git a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
new file mode 100644
index 0000000..3b9e928
--- /dev/null
+++ b/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
@@ -0,0 +1,19 @@
+"""
+Add new 'group:data:link-to-group' privilege.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'group:data:link-to-group',
+ 'Allow linking data to only one specific group.'
+ )
+ """,
+ "DELETE FROM privileges WHERE privilege_id='group:data:link-to-group'")
+]
diff --git a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
new file mode 100644
index 0000000..5d9c306
--- /dev/null
+++ b/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
@@ -0,0 +1,23 @@
+"""
+Assign 'group:data:link-to-group' privilege to group leader.
+"""
+
+from yoyo import step
+
+__depends__ = {'20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege'}
+
+steps = [
+ step(
+ """
+ INSERT INTO role_privileges(role_id, privilege_id)
+ VALUES(
+ 'a0e67630-d502-4b9f-b23f-6805d0f30e30',
+ 'group:data:link-to-group'
+ )
+ """,
+ """
+ DELETE FROM role_privileges
+ WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+ AND privilege_id='group:data:link-to-group'
+ """)
+]
diff --git a/mypy.ini b/mypy.ini
index 15c4b44..89a6200 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -71,4 +71,7 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-flask_cors.*]
+ignore_missing_imports = True
+
+[mypy-gn_libs.*]
ignore_missing_imports = True \ No newline at end of file
diff --git a/scripts/migrate_existing_data.py b/scripts/assign_data_to_default_admin.py
index 336ce72..69fc50c 100644
--- a/scripts/migrate_existing_data.py
+++ b/scripts/assign_data_to_default_admin.py
@@ -1,19 +1,19 @@
"""
-Migrate existing data that is not assigned to any group to the default sys-admin
-group for accessibility purposes.
+Assign any existing data (that is not currently assigned to any group) to the
+default sys-admin group for accessibility purposes.
"""
import sys
import json
import time
import random
+import logging
from pathlib import Path
from uuid import UUID, uuid4
import click
+from gn_libs import mysqldb as biodb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as biodb
-
import gn_auth.auth.db.sqlite3 as authdb
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles.models import (
@@ -21,12 +21,14 @@ from gn_auth.auth.authorisation.roles.models import (
from gn_auth.auth.authorisation.resources.groups.models import (
Group, save_group, add_resources_to_group)
-from gn_auth.auth.authorisation.resources.models import (
- Resource, ResourceCategory, __assign_resource_owner_role__)
+from gn_auth.auth.authorisation.resources.common import assign_resource_owner_role
+from gn_auth.auth.authorisation.resources.models import Resource, ResourceCategory
+
class DataNotFound(Exception):
"""Raise if no admin user exists."""
+
def sys_admins(conn: authdb.DbConnection) -> tuple[User, ...]:
"""Retrieve all the existing system admins."""
with authdb.cursor(conn) as cursor:
@@ -38,6 +40,7 @@ def sys_admins(conn: authdb.DbConnection) -> tuple[User, ...]:
return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
return tuple()
+
def choose_admin(enum_admins: dict[int, User]) -> int:
"""Prompt and read user choice."""
while True:
@@ -54,6 +57,7 @@ def choose_admin(enum_admins: dict[int, User]) -> int:
sys.exit(0)
print(f"\nERROR: Invalid choice '{choice}'!")
+
def select_sys_admin(admins: tuple[User, ...]) -> User:
"""Pick one admin out of list."""
if len(admins) > 0:
@@ -67,6 +71,7 @@ def select_sys_admin(admins: tuple[User, ...]) -> User:
raise DataNotFound(
"No administrator user found. Create an administrator user first.")
+
def admin_group(conn: authdb.DbConnection, admin: User) -> Group:
"""Retrieve the admin's user group. If none exist, create one."""
with authdb.cursor(conn) as cursor:
@@ -114,6 +119,7 @@ def admin_group(conn: authdb.DbConnection, admin: User) -> Group:
cursor, admin, UUID(grp_res["resource_id"]), "group-leader")
return new_group
+
def __resource_category_by_key__(
cursor: authdb.DbCursor, category_key: str) -> ResourceCategory:
"""Retrieve a resource category by its ID."""
@@ -128,6 +134,7 @@ def __resource_category_by_key__(
row["resource_category_key"],
row["resource_category_description"])
+
def __create_resources__(cursor: authdb.DbCursor) -> tuple[Resource, ...]:
"""Create default resources."""
resources = tuple(Resource(
@@ -147,6 +154,7 @@ def __create_resources__(cursor: authdb.DbCursor) -> tuple[Resource, ...]:
} for res in resources))
return resources
+
def default_resources(conn: authdb.DbConnection, group: Group) -> tuple[
Resource, ...]:
"""Create default resources, or return them if they exist."""
@@ -175,10 +183,12 @@ def default_resources(conn: authdb.DbConnection, group: Group) -> tuple[
tuple()
) for row in rows)
+
def delay():
"""Delay a while: anything from 2 seconds to 15 seconds."""
time.sleep(random.choice(range(2,16)))
+
def __assigned_mrna__(authconn):
"""Retrieve assigned mRNA items."""
with authdb.cursor(authconn) as cursor:
@@ -189,6 +199,7 @@ def __assigned_mrna__(authconn):
(row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
row["ProbeSetFreezeId"]) for row in cursor.fetchall())
+
def __unassigned_mrna__(bioconn, assigned):
"""Retrieve unassigned mRNA data items."""
query = (
@@ -198,11 +209,12 @@ def __unassigned_mrna__(bioconn, assigned):
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN ProbeFreeze AS pf "
"ON iset.InbredSetId=pf.InbredSetId INNER JOIN ProbeSetFreeze AS psf "
- "ON pf.ProbeFreezeId=psf.ProbeFreezeId ")
+ "ON pf.ProbeFreezeId=psf.ProbeFreezeId "
+ "WHERE s.Name != 'human' ")
if len(assigned) > 0:
paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(assigned))
query = query + (
- "WHERE (s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) "
+ "AND (s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) "
f"NOT IN ({paramstr}) ")
query = query + "LIMIT 100000"
@@ -210,6 +222,7 @@ def __unassigned_mrna__(bioconn, assigned):
cursor.execute(query, tuple(item for row in assigned for item in row))
return (row for row in cursor.fetchall())
+
def __assign_mrna__(authconn, bioconn, resource, group):
"Assign any unassigned mRNA data to resource."
while True:
@@ -238,6 +251,7 @@ def __assign_mrna__(authconn, bioconn, resource, group):
print(f"-> mRNA: Linked {len(unassigned)}")
delay()
+
def __assigned_geno__(authconn):
"""Retrieve assigned genotype data."""
with authdb.cursor(authconn) as cursor:
@@ -256,11 +270,12 @@ def __unassigned_geno__(bioconn, assigned):
"gf.ShortName AS dataset_shortname "
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN GenoFreeze AS gf "
- "ON iset.InbredSetId=gf.InbredSetId ")
+ "ON iset.InbredSetId=gf.InbredSetId "
+ "WHERE s.Name != 'human' ")
if len(assigned) > 0:
paramstr = ", ".join(["(%s, %s, %s)"] * len(assigned))
query = query + (
- "WHERE (s.SpeciesId, iset.InbredSetId, gf.Id) "
+ "AND (s.SpeciesId, iset.InbredSetId, gf.Id) "
f"NOT IN ({paramstr}) ")
query = query + "LIMIT 100000"
@@ -268,6 +283,7 @@ def __unassigned_geno__(bioconn, assigned):
cursor.execute(query, tuple(item for row in assigned for item in row))
return (row for row in cursor.fetchall())
+
def __assign_geno__(authconn, bioconn, resource, group):
"Assign any unassigned Genotype data to resource."
while True:
@@ -296,6 +312,7 @@ def __assign_geno__(authconn, bioconn, resource, group):
print(f"-> Genotype: Linked {len(unassigned)}")
delay()
+
def __assigned_pheno__(authconn):
"""Retrieve assigned phenotype data."""
with authdb.cursor(authconn) as cursor:
@@ -306,25 +323,27 @@ def __assigned_pheno__(authconn):
row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"],
row["PublishXRefId"]) for row in cursor.fetchall())
+
def __unassigned_pheno__(bioconn, assigned):
"""Retrieve all unassigned Phenotype data."""
query = (
- "SELECT spc.SpeciesId, iset.InbredSetId, "
- "pf.Id AS PublishFreezeId, pf.Name AS dataset_name, "
- "pf.FullName AS dataset_fullname, "
- "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
- "FROM "
- "Species AS spc "
- "INNER JOIN InbredSet AS iset "
- "ON spc.SpeciesId=iset.SpeciesId "
- "INNER JOIN PublishFreeze AS pf "
- "ON iset.InbredSetId=pf.InbredSetId "
- "INNER JOIN PublishXRef AS pxr "
- "ON pf.InbredSetId=pxr.InbredSetId ")
+ "SELECT spc.SpeciesId, iset.InbredSetId, "
+ "pf.Id AS PublishFreezeId, pf.Name AS dataset_name, "
+ "pf.FullName AS dataset_fullname, "
+ "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
+ "FROM "
+ "Species AS spc "
+ "INNER JOIN InbredSet AS iset "
+ "ON spc.SpeciesId=iset.SpeciesId "
+ "INNER JOIN PublishFreeze AS pf "
+ "ON iset.InbredSetId=pf.InbredSetId "
+ "INNER JOIN PublishXRef AS pxr "
+ "ON pf.InbredSetId=pxr.InbredSetId "
+ "WHERE spc.Name != 'human' ")
if len(assigned) > 0:
paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(assigned))
query = query + (
- "WHERE (spc.SpeciesId, iset.InbredSetId, pf.Id, pxr.Id) "
+ "AND (spc.SpeciesId, iset.InbredSetId, pf.Id, pxr.Id) "
f"NOT IN ({paramstr}) ")
query = query + "LIMIT 100000"
@@ -332,6 +351,7 @@ def __unassigned_pheno__(bioconn, assigned):
cursor.execute(query, tuple(item for row in assigned for item in row))
return (row for row in cursor.fetchall())
+
def __assign_pheno__(authconn, bioconn, resource, group):
"""Assign any unassigned Phenotype data to resource."""
while True:
@@ -360,6 +380,7 @@ def __assign_pheno__(authconn, bioconn, resource, group):
print(f"-> Phenotype: Linked {len(unassigned)}")
delay()
+
def assign_data_to_resource(
authconn, bioconn, resource: Resource, group: Group):
"""Assign existing data, not linked to any group to the resource."""
@@ -371,6 +392,7 @@ def assign_data_to_resource(
return assigner_fns[resource.resource_category.resource_category_key](
authconn, bioconn, resource, group)
+
def entry(authdbpath, mysqldburi):
"""Entry-point for data migration."""
if not Path(authdbpath).exists():
@@ -389,17 +411,24 @@ def entry(authdbpath, mysqldburi):
assign_data_to_resource(
authconn, bioconn, resource, the_admin_group)
with authdb.cursor(authconn) as cursor:
- __assign_resource_owner_role__(cursor, resource, admin)
+ assign_resource_owner_role(
+ cursor, resource.resource_id, admin.user_id)
except DataNotFound as dnf:
print(dnf.args[0], file=sys.stderr)
sys.exit(1)
+
@click.command()
@click.argument("authdbpath") # "Path to the Auth(entic|oris)ation database"
@click.argument("mysqldburi") # "URI to the MySQL database with the biology data"
-def run(authdbpath, mysqldburi):
+@click.option("--loglevel", default="WARNING", show_default=True,
+ type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]))
+def run(authdbpath, mysqldburi, loglevel):
"""Setup command-line arguments."""
+ globallogger = logging.getLogger()
+ globallogger.setLevel(loglevel)
entry(authdbpath, mysqldburi)
+
if __name__ == "__main__":
run() # pylint: disable=[no-value-for-parameter]
diff --git a/scripts/batch_assign_data_to_default_admin.py b/scripts/batch_assign_data_to_default_admin.py
new file mode 100644
index 0000000..a468019
--- /dev/null
+++ b/scripts/batch_assign_data_to_default_admin.py
@@ -0,0 +1,87 @@
+"""
+Similar to the 'assign_data_to_default_admin' script but without user
+interaction.
+"""
+import sys
+import logging
+from pathlib import Path
+
+import click
+from gn_libs import mysqldb as biodb
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.tools import monad_from_none_or_value
+
+from gn_auth.auth.db import sqlite3 as authdb
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.resources.groups.models import (
+ Group, db_row_to_group)
+
+from scripts.assign_data_to_default_admin import (
+ default_resources, assign_data_to_resource)
+
+
+def resources_group(conn: authdb.DbConnection) -> Maybe:
+ """Retrieve resources' group"""
+ with authdb.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT g.* FROM resources AS r "
+ "INNER JOIN resource_ownership AS ro "
+ "ON r.resource_id=ro.resource_id "
+ "INNER JOIN groups AS g ON ro.group_id=g.group_id "
+ "WHERE resource_name='mRNA-euhrin'")
+ return monad_from_none_or_value(
+ Nothing, Just, cursor.fetchone()).then(
+ db_row_to_group)
+
+
+def resource_owner(conn: authdb.DbConnection) -> Maybe:
+ """Retrieve the resource owner."""
+ with authdb.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT u.* FROM users AS u WHERE u.user_id IN "
+ "(SELECT ur.user_id FROM resources AS rsc "
+ "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id "
+ "INNER JOIN roles AS r on ur.role_id=r.role_id "
+ "WHERE resource_name='mRNA-euhrin' "
+ "AND r.role_name='resource-owner')")
+ return monad_from_none_or_value(
+ Nothing, Just, cursor.fetchone()).then(
+ User.from_sqlite3_row)
+
+
+def assign_data(authconn: authdb.DbConnection, bioconn, group: Group):
+ """Do actual data assignments."""
+ try:
+ for resource in default_resources(authconn, group):
+ assign_data_to_resource(authconn, bioconn, resource, group)
+
+ return 1
+ except Exception as _exc:# pylint: disable=[broad-except]
+ logging.error("Failed to assign some data!", exc_info=True)
+ return 1
+
+
+if __name__ == "__main__":
+ @click.command()
+ @click.argument("authdbpath") # "Path to the Auth(entic|oris)ation database"
+ @click.argument("mysqldburi") # "URI to the MySQL database with the biology data"
+ @click.option("--loglevel",
+ default="WARNING",
+ show_default=True,
+ type=click.Choice([
+ "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]))
+ def run(authdbpath, mysqldburi, loglevel):
+ """Script entry point."""
+ _logger = logging.getLogger()
+ _logger.setLevel(loglevel)
+ if Path(authdbpath).exists():
+ with (authdb.connection(authdbpath) as authconn,
+ biodb.database_connection(mysqldburi) as bioconn):
+ return resources_group(authconn).maybe(
+ 1,
+ lambda group: assign_data(authconn, bioconn, group))
+
+ logging.error("There is no such SQLite3 database file.")
+ return 1
+
+ sys.exit(run()) # pylint: disable=[no-value-for-parameter]
diff --git a/scripts/link_inbredsets.py b/scripts/link_inbredsets.py
index ac9fa2b..c78a050 100644
--- a/scripts/link_inbredsets.py
+++ b/scripts/link_inbredsets.py
@@ -6,12 +6,12 @@ import uuid
from pathlib import Path
import click
+from gn_libs import mysqldb as biodb
import gn_auth.auth.db.sqlite3 as authdb
-from gn_auth.auth.db import mariadb as biodb
-
-from scripts.migrate_existing_data import sys_admins, admin_group, select_sys_admin
+from scripts.assign_data_to_default_admin import (
+ sys_admins, admin_group, select_sys_admin)
def linked_inbredsets(conn):
"""Fetch all inbredset groups that are linked to the auth system."""
diff --git a/scripts/register_sys_admin.py b/scripts/register_sys_admin.py
index dfd4d59..06aa845 100644
--- a/scripts/register_sys_admin.py
+++ b/scripts/register_sys_admin.py
@@ -16,7 +16,7 @@ def fetch_email() -> str:
try:
user_input = input("Enter the administrator's email: ")
email = validate_email(user_input.strip(), check_deliverability=True)
- return email["email"]
+ return email["email"] # type: ignore
except EmailNotValidError as _enve:
print("You did not provide a valid email address. Try again...",
file=sys.stderr)
diff --git a/scripts/search_phenotypes.py b/scripts/search_phenotypes.py
index 20d91c9..eee112d 100644
--- a/scripts/search_phenotypes.py
+++ b/scripts/search_phenotypes.py
@@ -11,9 +11,9 @@ from datetime import datetime, timedelta
import click
import redis
import requests
+from gn_libs import mysqldb as gn3db
from gn_auth import jobs
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.settings import SQL_URI, AUTH_DB
from gn_auth.auth.authorisation.data.phenotypes import linked_phenotype_data
@@ -26,7 +26,7 @@ def do_search(
"""Do the search and return the results"""
search_uri = urljoin(host, (f"search/?page={page}&per_page={per_page}"
f"&type=phenotype&query={query}"))
- response = requests.get(search_uri)
+ response = requests.get(search_uri, timeout=300)
results = response.json()
if len(results) > 0:
return (item for item in results)
@@ -52,7 +52,7 @@ def update_search_results(redisconn: redis.Redis, redisname: str,
results: tuple[dict[str, Any], ...]):
"""Save the results to redis db."""
key = "search_results"
- prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]"))
+ prev_results = tuple(json.loads(redisconn.hget(redisname, key) or "[]")) # type: ignore
redisconn.hset(redisname, key, json.dumps(prev_results + results))
def expire_redis_results(redisconn: redis.Redis, redisname: str):
@@ -75,7 +75,7 @@ def expire_redis_results(redisconn: redis.Redis, redisname: str):
@click.option(
"--redis-uri", default="redis://:@localhost:6379/0",
help="The URI to the redis server.")
-def search(# pylint: disable=[too-many-arguments, too-many-locals]
+def search(# pylint: disable=[too-many-arguments, too-many-positional-arguments, too-many-locals]
species: str, query: str, job_id: uuid.UUID, host: str, per_page: int,
selected: str, auth_db_uri: str, gn3_db_uri: str, redis_uri: str):
"""
diff --git a/setup.py b/setup.py
index 023c2a2..77e5eb3 100755
--- a/setup.py
+++ b/setup.py
@@ -13,18 +13,18 @@ setup(author="Frederick M. Muriithi",
description=(
"Authentication/Authorisation server for GeneNetwork Services."),
install_requires=[
- "argon2-cffi>=20.1.0"
- "click"
- "Flask==1.1.2"
- "mypy==0.790"
- "mypy-extensions==0.4.3"
- "mysqlclient==2.0.1"
- "pylint==2.5.3"
- "pymonad"
- "redis==3.5.3"
- "requests==2.25.1"
- "flask-cors==3.0.9"
- "xapian-bindings"
+ "argon2-cffi>=20.1.0",
+ "click",
+ "Flask>=1.1.2",
+ "mypy>=0.790",
+ "mypy-extensions>=0.4.3",
+ "mysqlclient>=2.0.1",
+ "pylint>=2.5.3",
+ "pymonad",
+ "redis>=3.5.3",
+ "requests>=2.25.1",
+ "flask-cors>=3.0.9",
+ "gn-libs>=0.0.0"
],
include_package_data=True,
packages=find_packages(
diff --git a/tests/unit/auth/conftest.py b/tests/unit/auth/conftest.py
index 7f9d42d..fa86a4c 100644
--- a/tests/unit/auth/conftest.py
+++ b/tests/unit/auth/conftest.py
@@ -4,19 +4,41 @@ import datetime
from contextlib import contextmanager
from gn_auth.auth.authentication.oauth2.models.oauth2token import OAuth2Token
+from gn_auth.auth.authentication.oauth2.grants.jwt_bearer_grant import JWTBearerTokenGenerator
from .fixtures import * # pylint: disable=[wildcard-import,unused-wildcard-import]
-def get_tokeniser(user):
+SECRET_KEY = "this is the test secret key"
+SCOPE = "profile group role resource register-client"
+
+def _tokengenerator(user, client):
+ """Generate a JWT token for tests"""
+ _generator = JWTBearerTokenGenerator(
+ secret_key=SECRET_KEY,
+ alg="HS256")
+ return _generator(
+ grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
+ client=client,
+ user=user,
+ scope=SCOPE,
+ expires_in=3600,
+ include_refresh_token=False)
+
+def get_tokeniser(user, client):
"""Get contextmanager for mocking token acquisition."""
@contextmanager
def __token__(*args, **kwargs):# pylint: disable=[unused-argument]
yield {
usr.user_id: OAuth2Token(
token_id=uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
- client=None, token_type="Bearer", access_token="123456ABCDE",
- refresh_token=None, revoked=False, expires_in=864000,
- user=usr, issued_at=int(datetime.datetime.now().timestamp()),
+ client=client,
+ token_type="Bearer",
+ access_token=_tokengenerator(user, client),
+ refresh_token=None,
+ revoked=False,
+ expires_in=864000,
+ user=usr,
+ issued_at=int(datetime.datetime.now().timestamp()),
scope="profile group role resource register-client")
for usr in TEST_USERS
}[user.user_id]
diff --git a/tests/unit/auth/fixtures/group_fixtures.py b/tests/unit/auth/fixtures/group_fixtures.py
index 79683c0..2e8cd9a 100644
--- a/tests/unit/auth/fixtures/group_fixtures.py
+++ b/tests/unit/auth/fixtures/group_fixtures.py
@@ -4,10 +4,10 @@ import uuid
import pytest
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.authorisation.resources.groups import Group, GroupRole
+from gn_auth.auth.authorisation.resources.groups import Group
from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
-from .role_fixtures import RESOURCE_EDITOR_ROLE, RESOURCE_READER_ROLE
+from .resource_fixtures import TEST_RESOURCES
TEST_GROUP_01 = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"),
"TheTestGroup", {})
@@ -15,16 +15,6 @@ TEST_GROUP_02 = Group(uuid.UUID("e37d59d7-c05e-4d67-b479-81e627d8d634"),
"AnotherTestGroup", {})
TEST_GROUPS = (TEST_GROUP_01, TEST_GROUP_02)
-SYSTEM_CATEGORY = ResourceCategory(
- uuid.UUID("aa3d787f-af6a-44fa-9b0b-c82d40e54ad2"),
- "system",
- "The overall system.")
-SYSTEM_RESOURCE = Resource(
- uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
- "GeneNetwork System",
- SYSTEM_CATEGORY,
- True)
-
GROUP_CATEGORY = ResourceCategory(
uuid.UUID("1e0f70ee-add5-4358-8c6c-43de77fa4cce"),
"group",
@@ -46,38 +36,11 @@ GROUP_RESOURCES = tuple(
False)
for row in GROUPS_AS_RESOURCES)
-TEST_RESOURCES_GROUP_01 = (
- Resource(uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"),
- "ResourceG01R01",
- ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
- "genotype", "Genotype Dataset"),
- True),
- Resource(uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
- "ResourceG01R02",
- ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"),
- "phenotype", "Phenotype (Publish) Dataset"),
- False),
- Resource(uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
- "ResourceG01R03",
- ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
- "mrna", "mRNA Dataset"),
- False))
-
-TEST_RESOURCES_GROUP_02 = (
- Resource(uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"),
- "ResourceG02R01",
- ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
- "genotype", "Genotype Dataset"),
- False),
- Resource(uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
- "ResourceG02R02",
- ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
- "mrna", "mRNA Dataset"),
- True))
-
-TEST_RESOURCES = TEST_RESOURCES_GROUP_01 + TEST_RESOURCES_GROUP_02
-TEST_RESOURCES_PUBLIC = (
- SYSTEM_RESOURCE, TEST_RESOURCES_GROUP_01[0], TEST_RESOURCES_GROUP_02[1])
+
+TEST_RESOURCES_GROUP_01 = TEST_RESOURCES[0:3]
+TEST_RESOURCES_GROUP_02 = TEST_RESOURCES[3:5]
+
+
def __gtuple__(cursor):
return tuple(dict(row) for row in cursor.fetchall())
@@ -115,6 +78,37 @@ def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-na
"DELETE FROM groups WHERE group_id=?",
((str(group.group_id),) for group in TEST_GROUPS))
+
+@pytest.fixture(scope="function")
+def fxtr_resource_ownership(# pylint: disable=[redefined-outer-name]
+ fxtr_resources, fxtr_group
+):
+ """fixture: Set up group ownership of resources."""
+ _conn, resources = fxtr_resources
+ conn, groups = fxtr_group
+ ownership = tuple({
+ "group_id": str(TEST_GROUP_01.group_id),
+ "resource_id": str(res.resource_id)
+ } for res in TEST_RESOURCES_GROUP_01) + tuple({
+ "group_id": str(TEST_GROUP_02.group_id),
+ "resource_id": str(res.resource_id)
+ } for res in TEST_RESOURCES_GROUP_02)
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ "INSERT INTO resource_ownership(group_id, resource_id) "
+ "VALUES (:group_id, :resource_id)",
+ ownership)
+
+ yield conn, resources, groups, ownership
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ "DELETE FROM resource_ownership "
+ "WHERE group_id=:group_id AND resource_id=:resource_id",
+ ownership)
+
+
@pytest.fixture(scope="function")
def fxtr_users_in_group(fxtr_group, fxtr_users):# pylint: disable=[redefined-outer-name, unused-argument]
"""Link the users to the groups."""
@@ -134,60 +128,3 @@ def fxtr_users_in_group(fxtr_group, fxtr_users):# pylint: disable=[redefined-out
cursor.executemany(
"DELETE FROM group_users WHERE group_id=? AND user_id=?",
query_params)
-
-@pytest.fixture(scope="function")
-def fxtr_group_roles(fxtr_group, fxtr_roles):# pylint: disable=[redefined-outer-name,unused-argument]
- """Link roles to group"""
- group_roles = (
- GroupRole(uuid.UUID("9c25efb2-b477-4918-a95c-9914770cbf4d"),
- TEST_GROUP_01, RESOURCE_EDITOR_ROLE),
- GroupRole(uuid.UUID("82aed039-fe2f-408c-ab1e-81cd1ba96630"),
- TEST_GROUP_02, RESOURCE_READER_ROLE))
- conn, groups = fxtr_group
- with db.cursor(conn) as cursor:
- cursor.executemany(
- "INSERT INTO group_roles VALUES (?, ?, ?)",
- ((str(role.group_role_id), str(role.group.group_id),
- str(role.role.role_id))
- for role in group_roles))
-
- yield conn, groups, group_roles
-
- with db.cursor(conn) as cursor:
- cursor.executemany(
- ("DELETE FROM group_roles "
- "WHERE group_role_id=? AND group_id=? AND role_id=?"),
- ((str(role.group_role_id), str(role.group.group_id),
- str(role.role.role_id))
- for role in group_roles))
-
-@pytest.fixture(scope="function")
-def fxtr_group_user_roles(fxtr_resources, fxtr_group_roles, fxtr_users_in_group):#pylint: disable=[redefined-outer-name,unused-argument]
- """Assign roles to users."""
- conn, _groups, group_roles = fxtr_group_roles
- _conn, group_resources = fxtr_resources
- _conn, _group, group_users = fxtr_users_in_group
- users = tuple(user for user in group_users if user.email
- not in ("unaff@iliated.user", "group@lead.er"))
- users_roles_resources = (
- (user, RESOURCE_EDITOR_ROLE, TEST_RESOURCES_GROUP_01[1])
- for user in users if user.email == "group@mem.ber01")
- with db.cursor(conn) as cursor:
- params = tuple({
- "user_id": str(user.user_id),
- "role_id": str(role.role_id),
- "resource_id": str(resource.resource_id)
- } for user, role, resource in users_roles_resources)
- cursor.executemany(
- ("INSERT INTO user_roles "
- "VALUES (:user_id, :role_id, :resource_id)"),
- params)
-
- yield conn, group_users, group_roles, group_resources
-
- with db.cursor(conn) as cursor:
- cursor.executemany(
- ("DELETE FROM user_roles WHERE "
- "user_id=:user_id AND role_id=:role_id AND "
- "resource_id=:resource_id"),
- params)
diff --git a/tests/unit/auth/fixtures/resource_fixtures.py b/tests/unit/auth/fixtures/resource_fixtures.py
index 7f3c383..e06f64e 100644
--- a/tests/unit/auth/fixtures/resource_fixtures.py
+++ b/tests/unit/auth/fixtures/resource_fixtures.py
@@ -1,45 +1,65 @@
"""Fixtures and utilities for resource-related tests"""
+import uuid
+
import pytest
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
+
+
+SYSTEM_CATEGORY = ResourceCategory(
+ uuid.UUID("aa3d787f-af6a-44fa-9b0b-c82d40e54ad2"),
+ "system",
+ "The overall system.")
+SYSTEM_RESOURCE = Resource(
+ uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
+ "GeneNetwork System",
+ SYSTEM_CATEGORY,
+ True)
+
+TEST_RESOURCES = (
+ Resource(uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"),
+ "ResourceG01R01",
+ ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
+ "genotype", "Genotype Dataset"),
+ True),
+ Resource(uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
+ "ResourceG01R02",
+ ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"),
+ "phenotype", "Phenotype (Publish) Dataset"),
+ False),
+ Resource(uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
+ "ResourceG01R03",
+ ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
+ "mrna", "mRNA Dataset"),
+ False),
+ Resource(uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"),
+ "ResourceG02R01",
+ ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
+ "genotype", "Genotype Dataset"),
+ False),
+ Resource(uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
+ "ResourceG02R02",
+ ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
+ "mrna", "mRNA Dataset"),
+ True))
+
+TEST_RESOURCES_PUBLIC = (SYSTEM_RESOURCE, TEST_RESOURCES[0], TEST_RESOURCES[4])
-from .group_fixtures import (
- TEST_RESOURCES,
- TEST_GROUP_01,
- TEST_GROUP_02,
- TEST_RESOURCES_GROUP_01,
- TEST_RESOURCES_GROUP_02)
@pytest.fixture(scope="function")
-def fxtr_resources(fxtr_group):# pylint: disable=[redefined-outer-name]
+def fxtr_resources(conn_after_auth_migrations):
"""fixture: setup test resources in the database"""
- conn, _group = fxtr_group
- ownership = tuple({
- "group_id": str(TEST_GROUP_01.group_id),
- "resource_id": str(res.resource_id)
- } for res in TEST_RESOURCES_GROUP_01) + tuple({
- "group_id": str(TEST_GROUP_02.group_id),
- "resource_id": str(res.resource_id)
- } for res in TEST_RESOURCES_GROUP_02)
-
+ conn = conn_after_auth_migrations
with db.cursor(conn) as cursor:
cursor.executemany(
"INSERT INTO resources VALUES (?,?,?,?)",
- ((str(res.resource_id), res.resource_name,
- str(res.resource_category.resource_category_id),
- 1 if res.public else 0) for res in TEST_RESOURCES))
- cursor.executemany(
- "INSERT INTO resource_ownership(group_id, resource_id) "
- "VALUES (:group_id, :resource_id)",
- ownership)
+ ((str(res.resource_id), res.resource_name,
+ str(res.resource_category.resource_category_id),
+ 1 if res.public else 0) for res in TEST_RESOURCES))
yield (conn, TEST_RESOURCES)
with db.cursor(conn) as cursor:
- cursor.executemany(
- "DELETE FROM resource_ownership "
- "WHERE group_id=:group_id AND resource_id=:resource_id",
- ownership)
cursor.executemany("DELETE FROM resources WHERE resource_id=?",
- ((str(res.resource_id),)
- for res in TEST_RESOURCES))
+ ((str(res.resource_id),) for res in TEST_RESOURCES))
diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py
index ddcbba5..63a3fca 100644
--- a/tests/unit/auth/fixtures/role_fixtures.py
+++ b/tests/unit/auth/fixtures/role_fixtures.py
@@ -7,18 +7,41 @@ from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
+from .user_fixtures import TEST_USERS
+from .resource_fixtures import SYSTEM_RESOURCE, TEST_RESOURCES_PUBLIC
+from .group_fixtures import (
+ TEST_GROUP_01,
+ TEST_RESOURCES_GROUP_01,
+ TEST_RESOURCES_GROUP_02)
+
+PUBLIC_VIEW_ROLE = Role(
+ uuid.UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ "public-view",
+ False,
+ (Privilege("group:resource:view-resource",
+ "view a resource and use it in computations"),))
+
RESOURCE_READER_ROLE = Role(
- uuid.UUID("c3ca2507-ee24-4835-9b31-8c21e1c072d3"), "resource_reader", True,
+ uuid.UUID("c3ca2507-ee24-4835-9b31-8c21e1c072d3"), "resource_reader",
+ True,
(Privilege("group:resource:view-resource",
"view a resource and use it in computations"),))
RESOURCE_EDITOR_ROLE = Role(
- uuid.UUID("89819f84-6346-488b-8955-86062e9eedb7"), "resource_editor", True,
+ uuid.UUID("89819f84-6346-488b-8955-86062e9eedb7"),
+ "resource_editor",
+ True,
(
Privilege("group:resource:view-resource",
"view a resource and use it in computations"),
Privilege("group:resource:edit-resource", "edit/update a resource")))
+CREATE_GROUP_ROLE = Role(
+ uuid.UUID("ade7e6b0-ba9c-4b51-87d0-2af7fe39a347"),
+ "group-creator",
+ False,
+ (Privilege("system:group:create-group", "Create a group"),))
+
TEST_ROLES = (RESOURCE_READER_ROLE, RESOURCE_EDITOR_ROLE)
@pytest.fixture(scope="function")
@@ -43,3 +66,145 @@ def fxtr_roles(conn_after_auth_migrations):
cursor.executemany(
("DELETE FROM roles WHERE role_id=?"),
((str(role.role_id),) for role in TEST_ROLES))
+
+
+@pytest.fixture(scope="function")
+def fxtr_resource_roles(fxtr_resources, fxtr_roles):# pylint: disable=[redefined-outer-name,unused-argument]
+ """Link roles to resources."""
+ resource_roles = ({
+ "resource_id": str(TEST_RESOURCES_GROUP_01[0].resource_id),
+ "role_created_by": "ecb52977-3004-469e-9428-2a1856725c7f",
+ "role_id": str(RESOURCE_EDITOR_ROLE.role_id)
+ },{
+ "resource_id": str(TEST_RESOURCES_GROUP_01[0].resource_id),
+ "role_created_by": "ecb52977-3004-469e-9428-2a1856725c7f",
+ "role_id": str(RESOURCE_READER_ROLE.role_id)
+ }, {
+ "resource_id": str(TEST_RESOURCES_GROUP_02[1].resource_id),
+ "role_created_by": "ecb52977-3004-469e-9428-2a1856725c7f",
+ "role_id": str(RESOURCE_EDITOR_ROLE.role_id)
+ },{
+ "resource_id": str(TEST_RESOURCES_GROUP_02[1].resource_id),
+ "role_created_by": "ecb52977-3004-469e-9428-2a1856725c7f",
+ "role_id": str(RESOURCE_READER_ROLE.role_id)
+ })
+
+ conn, resources = fxtr_resources
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ "INSERT INTO resource_roles(resource_id, role_created_by, role_id) "
+ "VALUES (:resource_id, :role_created_by, :role_id)",
+ resource_roles)
+
+ yield conn, resources, resource_roles
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ ("DELETE FROM resource_roles "
+ "WHERE resource_id=:resource_id "
+ "AND role_created_by=:role_created_by "
+ "AND role_id=:role_id"),
+ resource_roles)
+
+
+@pytest.fixture(scope="function")
+def fxtr_setup_group_leaders(fxtr_users):
+ """Define what roles users have that target resources of type 'Group'."""
+ conn, users = fxtr_users
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM group_resources")
+ g01res_id = {
+ row["group_id"]: row["resource_id"]
+ for row in cursor.fetchall()
+ }[str(TEST_GROUP_01.group_id)]
+ test_user_roles = ({
+ "user_id": "ecb52977-3004-469e-9428-2a1856725c7f",
+ "role_id": "a0e67630-d502-4b9f-b23f-6805d0f30e30",# group-leader
+ "resource_id": g01res_id
+ },)
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES (:user_id, :role_id, :resource_id)",
+ test_user_roles)
+
+ yield conn, users
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ "DELETE FROM user_roles WHERE user_id=:user_id "
+ "AND role_id=:role_id AND resource_id=:resource_id",
+ test_user_roles)
+
+
+@pytest.fixture(scope="function")
+def fxtr_system_roles(fxtr_users):
+ """Define what roles users have that target resources of type 'Group'."""
+ conn, users = fxtr_users
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT * FROM resources WHERE resource_name='GeneNetwork System'")
+ sysres_id = cursor.fetchone()["resource_id"]
+ test_user_roles = tuple({
+ "user_id": str(user.user_id),
+ "role_id": str(PUBLIC_VIEW_ROLE.role_id),
+ "resource_id": sysres_id
+ } for user in TEST_USERS)
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES (:user_id, :role_id, :resource_id)",
+ test_user_roles)
+
+ yield conn, users
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ "DELETE FROM user_roles WHERE user_id=:user_id "
+ "AND role_id=:role_id AND resource_id=:resource_id",
+ test_user_roles)
+
+
+@pytest.fixture(scope="function")
+def fxtr_resource_user_roles(# pylint: disable=[too-many-arguments, too-many-locals, too-many-positional-arguments]
+ fxtr_resources,
+ fxtr_users_in_group,
+ fxtr_resource_ownership,
+ fxtr_resource_roles,
+ fxtr_setup_group_leaders,
+ fxtr_system_roles
+):#pylint: disable=[redefined-outer-name,unused-argument]
+ """Assign roles to users."""
+ _conn, group_resources = fxtr_resources
+ _conn, _resources, _groups, group_resources = fxtr_resource_ownership
+ _conn, _group, group_users = fxtr_users_in_group
+ conn, _groups, resource_roles = fxtr_resource_roles
+
+ users_roles_resources = (
+ # Give access to group leader to all resources in their group
+ tuple((TEST_USERS[0], RESOURCE_EDITOR_ROLE, resource)
+ for resource in TEST_RESOURCES_GROUP_01)
+ # Set group member as resource editor
+ + ((TEST_USERS[1], RESOURCE_EDITOR_ROLE, TEST_RESOURCES_GROUP_01[1]),)
+ # Set group-creator role on the unaffiliated user
+ + ((TEST_USERS[3], CREATE_GROUP_ROLE, SYSTEM_RESOURCE),)
+ # Set roles for public resources
+ + tuple(
+ (user, PUBLIC_VIEW_ROLE, resource)
+ for user in TEST_USERS for resource in TEST_RESOURCES_PUBLIC[1:]))
+ with db.cursor(conn) as cursor:
+ params = tuple({
+ "user_id": str(user.user_id),
+ "role_id": str(role.role_id),
+ "resource_id": str(resource.resource_id)
+ } for user, role, resource in users_roles_resources)
+ cursor.executemany(
+ ("INSERT INTO user_roles "
+ "VALUES (:user_id, :role_id, :resource_id)"),
+ params)
+
+ yield conn, group_users, resource_roles, group_resources
+
+ with db.cursor(conn) as cursor:
+ cursor.executemany(
+ ("DELETE FROM user_roles WHERE "
+ "user_id=:user_id AND role_id=:role_id AND "
+ "resource_id=:resource_id"),
+ params)
diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py
index b88d78a..1cf0e20 100644
--- a/tests/unit/auth/fixtures/user_fixtures.py
+++ b/tests/unit/auth/fixtures/user_fixtures.py
@@ -6,8 +6,6 @@ import pytest
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, hash_password
-from .group_fixtures import TEST_GROUP_01
-
TEST_USERS = (
User(uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er",
"Group Leader"),
@@ -25,29 +23,6 @@ def fxtr_users(conn_after_auth_migrations, fxtr_group):# pylint: disable=[redefi
with db.cursor(conn_after_auth_migrations) as cursor:
cursor.executemany(query, (
(str(user.user_id), user.email, user.name) for user in TEST_USERS))
- # setup user roles
- cursor.execute("SELECT * FROM group_resources")
- g01res_id = {
- row["group_id"]: row["resource_id"]
- for row in cursor.fetchall()
- }[str(TEST_GROUP_01.group_id)]
- cursor.execute("SELECT * FROM resources WHERE resource_name='GeneNetwork System'")
- sysres_id = cursor.fetchone()["resource_id"]
- test_user_roles = (
- {
- "user_id": "ecb52977-3004-469e-9428-2a1856725c7f",
- "role_id": "a0e67630-d502-4b9f-b23f-6805d0f30e30",# group-leader
- "resource_id": g01res_id
- },
- {
- "user_id": "ecb52977-3004-469e-9428-2a1856725c7f",
- "role_id": "ade7e6b0-ba9c-4b51-87d0-2af7fe39a347",# group-creator
- "resource_id": sysres_id
- })
- cursor.executemany(
- "INSERT INTO user_roles(user_id, role_id, resource_id) "
- "VALUES (:user_id, :role_id, :resource_id)",
- test_user_roles)
yield (conn_after_auth_migrations, TEST_USERS)
diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py
index c9d8b19..346beb9 100644
--- a/tests/unit/auth/test_groups.py
+++ b/tests/unit/auth/test_groups.py
@@ -6,11 +6,9 @@ from pymonad.maybe import Nothing
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import AuthorisationError
-from gn_auth.auth.authentication.users import User
-from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.resources.groups.models import (
- Group, GroupRole, user_group, create_group, create_group_role)
+ Group, user_group, create_group, create_group_role)
from tests.unit.auth import conftest
@@ -28,40 +26,96 @@ PRIVILEGES = (
Privilege("group:resource:edit-resource", "edit/update a resource"))
@pytest.mark.unit_test
+@pytest.mark.parametrize("user", tuple(conftest.TEST_USERS[0:3]))
+def test_create_group_fails(# pylint: disable=[too-many-arguments too-many-positional-arguments]
+ fxtr_app, auth_testdb_path, mocker, fxtr_resource_user_roles, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument]
+ """
+ GIVEN: an authenticated user
+ WHEN: the user attempts to create a group
+ THEN: verify they are only able to create the group if they have the
+ appropriate privileges
+ """
+ _conn, clients = fxtr_oauth2_clients
+ mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
+ with db.connection(auth_testdb_path) as conn:
+ with pytest.raises(AuthorisationError):
+ create_group(conn, "a_test_group", user, "A test group")
+
+
+def __cleanup_create_group__(conn, user, group):
+ """Cleanup creating a group..."""
+ # cleanup: This should probably go into a 'delete_group(…) function'
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM group_users WHERE group_id=? AND user_id=?",
+ (str(group.group_id), str(user.user_id)))
+ cursor.execute("SELECT * FROM group_resources WHERE group_id=?",
+ (str(group.group_id),))
+ grp_rsc = cursor.fetchone()
+ cursor.execute(
+ "DELETE FROM user_roles WHERE user_id=? AND resource_id=?",
+ (str(user.user_id), str(grp_rsc["resource_id"])))
+ cursor.execute("DELETE FROM group_resources WHERE group_id=?",
+ (str(group.group_id),))
+ cursor.execute("DELETE FROM groups WHERE group_id=?",
+ (str(group.group_id),))
+
+
+@pytest.mark.unit_test
@pytest.mark.parametrize(
- "user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
- Group(
- UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group",
- {"group_description": "A test group"}),
- create_group_failure, create_group_failure, create_group_failure,
- create_group_failure))))
-def test_create_group(# pylint: disable=[too-many-arguments]
- fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument]
+ "user,expected",
+ ((conftest.TEST_USERS[3], Group(
+ UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group",
+ {"group_description": "A test group"})),))
+def test_create_group_succeeds(# pylint: disable=[too-many-arguments too-many-positional-arguments, unused-argument]
+ fxtr_app,
+ auth_testdb_path,
+ mocker,
+ fxtr_resource_user_roles,
+ fxtr_oauth2_clients,
+ user,
+ expected
+):
"""
GIVEN: an authenticated user
WHEN: the user attempts to create a group
THEN: verify they are only able to create the group if they have the
appropriate privileges
"""
+ _conn, clients = fxtr_oauth2_clients
mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
with db.connection(auth_testdb_path) as conn:
- assert create_group(
- conn, "a_test_group", user, "A test group") == expected
+ created_group = create_group(
+ conn, "a_test_group", user, "A test group")
+ assert created_group == expected
+ __cleanup_create_group__(conn, user, created_group)
+
@pytest.mark.unit_test
@pytest.mark.parametrize("user", conftest.TEST_USERS[1:])
-def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments]
- fxtr_app, auth_testdb_path, mocker, fxtr_users, user):# pylint: disable=[unused-argument]
+def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments too-many-positional-arguments]
+ fxtr_app, auth_testdb_path, mocker, fxtr_users, fxtr_oauth2_clients, user):# pylint: disable=[unused-argument]
"""
GIVEN: an authenticated user, without appropriate privileges
WHEN: the user attempts to create a group
THEN: verify the system raises an exception
"""
+ _conn, clients = fxtr_oauth2_clients
mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
with db.connection(auth_testdb_path) as conn:
with pytest.raises(AuthorisationError):
assert create_group(conn, "a_test_group", user, "A test group")
@@ -71,58 +125,34 @@ create_role_failure = {
"message": "Unauthorised: Could not create the group role"
}
-@pytest.mark.unit_test
-@pytest.mark.parametrize(
- "user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
- GroupRole(
- UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
- GROUP,
- Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
- "ResourceEditor", True, PRIVILEGES)),))))
-def test_create_group_role(mocker, fxtr_users_in_group, user, expected):
- """
- GIVEN: an authenticated user
- WHEN: the user attempts to create a role, attached to a group
- THEN: verify they are only able to create the role if they have the
- appropriate privileges and that the role is attached to the given group
- """
- mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
- conn, _group, _users = fxtr_users_in_group
- with db.cursor(conn) as cursor:
- assert create_group_role(
- conn, GROUP, "ResourceEditor", PRIVILEGES) == expected
- # cleanup
- cursor.execute(
- ("DELETE FROM group_roles "
- "WHERE group_role_id=? AND group_id=? AND role_id=?"),
- (str(conftest.uuid_fn()), str(GROUP.group_id), str(conftest.uuid_fn())))
@pytest.mark.unit_test
@pytest.mark.parametrize(
"user,expected", tuple(zip(conftest.TEST_USERS[1:], (
create_role_failure, create_role_failure, create_role_failure))))
def test_create_group_role_raises_exception_with_unauthorised_users(
- mocker, fxtr_users_in_group, user, expected):
+ mocker, fxtr_users_in_group, fxtr_oauth2_clients, user, expected):
"""
GIVEN: an authenticated user
WHEN: the user attempts to create a role, attached to a group
THEN: verify they are only able to create the role if they have the
appropriate privileges and that the role is attached to the given group
"""
+ _conn, clients = fxtr_oauth2_clients
mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
conn, _group, _users = fxtr_users_in_group
with pytest.raises(AuthorisationError):
assert create_group_role(
conn, GROUP, "ResourceEditor", PRIVILEGES) == expected
@pytest.mark.unit_test
-def test_create_multiple_groups(mocker, fxtr_users):
+def test_create_multiple_groups(mocker, fxtr_resource_user_roles, fxtr_oauth2_clients):
"""
GIVEN: An authenticated user with appropriate authorisation
WHEN: The user attempts to create a new group, while being a member of an
@@ -130,21 +160,26 @@ def test_create_multiple_groups(mocker, fxtr_users):
THEN: The system should prevent that, and respond with an appropriate error
message
"""
+ _conn, clients = fxtr_oauth2_clients
mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
- user = User(
- UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er",
- "Group Leader")
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
- conn, _test_users = fxtr_users
+ user = conftest.TEST_USERS[3]
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
+ conn, *_test_users = fxtr_resource_user_roles
# First time, successfully creates the group
- assert create_group(conn, "a_test_group", user) == Group(
+ created_group = create_group(conn, "a_test_group", user)
+ assert created_group == Group(
UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group",
{})
# subsequent attempts should fail
with pytest.raises(AuthorisationError):
create_group(conn, "another_test_group", user)
+ __cleanup_create_group__(conn, user, created_group)
+
@pytest.mark.unit_test
@pytest.mark.parametrize(
"user,expected",
diff --git a/tests/unit/auth/test_migrations_add_data_to_table.py b/tests/unit/auth/test_migrations_add_data_to_table.py
index d9e2ca4..0945a20 100644
--- a/tests/unit/auth/test_migrations_add_data_to_table.py
+++ b/tests/unit/auth/test_migrations_add_data_to_table.py
@@ -40,7 +40,7 @@ test_params = (
@pytest.mark.unit_test
@pytest.mark.parametrize("migration_file,query,query_params,data", test_params)
-def test_apply_insert(# pylint: disable=[too-many-arguments]
+def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_migrations_dir, backend, auth_testdb_path, migration_file, query,
query_params, data):
"""
@@ -65,7 +65,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize("migration_file,query,query_params,data", test_params)
-def test_rollback_insert(# pylint: disable=[too-many-arguments]
+def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_migrations_dir, backend, auth_testdb_path, migration_file, query,
query_params, data):
"""
diff --git a/tests/unit/auth/test_migrations_add_remove_columns.py b/tests/unit/auth/test_migrations_add_remove_columns.py
index af85652..15dc3a2 100644
--- a/tests/unit/auth/test_migrations_add_remove_columns.py
+++ b/tests/unit/auth/test_migrations_add_remove_columns.py
@@ -51,7 +51,7 @@ def rolled_back_successfully(adding: bool, result_str: str, column: str) -> bool
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_column,adding", TEST_PARAMS)
-def test_apply_add_remove_column(# pylint: disable=[too-many-arguments]
+def test_apply_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_migrations_dir, auth_testdb_path, backend, migration_file,
the_table, the_column, adding):
"""
@@ -84,7 +84,7 @@ def test_apply_add_remove_column(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_column,adding", TEST_PARAMS)
-def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments]
+def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_migrations_dir, auth_testdb_path, backend, migration_file,
the_table, the_column, adding):
"""
diff --git a/tests/unit/auth/test_migrations_indexes.py b/tests/unit/auth/test_migrations_indexes.py
index 1c543c4..2d0997f 100644
--- a/tests/unit/auth/test_migrations_indexes.py
+++ b/tests/unit/auth/test_migrations_indexes.py
@@ -30,7 +30,7 @@ migrations_tables_and_indexes = (
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_index", migrations_tables_and_indexes)
-def test_index_created(# pylint: disable=[too-many-arguments]
+def test_index_created(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
the_table, the_index):
"""
@@ -61,7 +61,7 @@ def test_index_created(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,the_table,the_index", migrations_tables_and_indexes)
-def test_index_dropped(# pylint: disable=[too-many-arguments]
+def test_index_dropped(# pylint: disable=[too-many-arguments too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
the_table, the_index):
"""
diff --git a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
index 0cf9a1f..c699e81 100644
--- a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
+++ b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py
@@ -16,7 +16,7 @@ test_params = (
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,table,row_count", test_params)
-def test_apply_insert(# pylint: disable=[too-many-arguments]
+def test_apply_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
table, row_count):
"""
@@ -45,7 +45,7 @@ def test_apply_insert(# pylint: disable=[too-many-arguments]
@pytest.mark.unit_test
@pytest.mark.parametrize(
"migration_file,table,row_count", test_params)
-def test_rollback_insert(# pylint: disable=[too-many-arguments]
+def test_rollback_insert(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
auth_testdb_path, auth_migrations_dir, backend, migration_file,
table, row_count):
"""
diff --git a/tests/unit/auth/test_privileges.py b/tests/unit/auth/test_privileges.py
index 0b5f120..9b2ea04 100644
--- a/tests/unit/auth/test_privileges.py
+++ b/tests/unit/auth/test_privileges.py
@@ -11,8 +11,7 @@ def sort_key_privileges(priv):
return priv.privilege_id
PRIVILEGES = sorted(
- (Privilege("system:group:create-group", "Create a group"),
- Privilege("system:group:view-group", "View the details of a group"),
+ (Privilege("system:group:view-group", "View the details of a group"),
Privilege("system:group:edit-group", "Edit the details of a group"),
Privilege("system:user:list", "List users in the system"),
Privilege("system:group:delete-group", "Delete a group"),
@@ -27,17 +26,15 @@ PRIVILEGES = sorted(
Privilege("group:resource:edit-resource", "edit/update a resource"),
Privilege("group:resource:delete-resource", "Delete a resource"),
- Privilege("group:role:create-role", "Create a new role"),
- Privilege("group:role:edit-role", "edit/update an existing role"),
- Privilege("group:user:assign-role", "Assign a role to an existing user"),
- Privilege("group:role:delete-role", "Delete an existing role")),
+ Privilege("group:data:link-to-group",
+ "Allow linking data to only one specific group.")),
key=sort_key_privileges)
@pytest.mark.unit_test
@pytest.mark.parametrize(
"user,expected", tuple(zip(
conftest.TEST_USERS, (PRIVILEGES, [], [], [], []))))
-def test_user_privileges(auth_testdb_path, fxtr_users, user, expected):# pylint: disable=[unused-argument]
+def test_user_privileges(auth_testdb_path, fxtr_setup_group_leaders, user, expected):# pylint: disable=[unused-argument]
"""
GIVEN: A user
WHEN: An attempt is made to fetch the user's privileges
diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py
index 85641be..292f7dc 100644
--- a/tests/unit/auth/test_resources.py
+++ b/tests/unit/auth/test_resources.py
@@ -30,17 +30,28 @@ create_resource_failure = {
(Resource(
uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
"test_resource", resource_category, False),))))
-def test_create_resource(mocker, fxtr_users_in_group, user, expected):
+def test_create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument]
+ mocker,
+ fxtr_users_in_group,
+ fxtr_resource_user_roles,
+ fxtr_oauth2_clients,
+ user,
+ expected
+):
"""Test that resource creation works as expected."""
mocker.patch("gn_auth.auth.authorisation.resources.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ _conn, clients = fxtr_oauth2_clients
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
conn, _group, _users = fxtr_users_in_group
- resource = create_resource(
- conn, "test_resource", resource_category, user, False)
- assert resource == expected
with db.cursor(conn) as cursor:
+ resource = create_resource(
+ cursor, "test_resource", resource_category, user, _group, False)
+ assert resource == expected
# Cleanup
cursor.execute(
"DELETE FROM user_roles WHERE resource_id=?",
@@ -49,9 +60,6 @@ def test_create_resource(mocker, fxtr_users_in_group, user, expected):
"DELETE FROM resource_ownership WHERE resource_id=?",
(str(resource.resource_id),))
cursor.execute(
- "DELETE FROM group_roles WHERE group_id=?",
- (str(group.group_id),))
- cursor.execute(
"DELETE FROM resources WHERE resource_id=?",
(str(resource.resource_id),))
@@ -63,15 +71,26 @@ def test_create_resource(mocker, fxtr_users_in_group, user, expected):
(create_resource_failure, create_resource_failure,
create_resource_failure))))
def test_create_resource_raises_for_unauthorised_users(
- mocker, fxtr_users_in_group, user, expected):
+ mocker, fxtr_users_in_group, fxtr_oauth2_clients, user, expected):
"""Test that resource creation works as expected."""
mocker.patch("gn_auth.auth.authorisation.resources.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ _conn, clients = fxtr_oauth2_clients
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
conn, _group, _users = fxtr_users_in_group
with pytest.raises(AuthorisationError):
- assert create_resource(
- conn, "test_resource", resource_category, user, False) == expected
+ with db.cursor(conn) as cursor:
+ assert create_resource(
+ cursor,
+ "test_resource",
+ resource_category,
+ user,
+ _group,
+ False
+ ) == expected
def sort_key_resources(resource):
"""Sort-key for resources."""
@@ -109,13 +128,13 @@ def test_public_resources(fxtr_resources):
,
key=sort_key_resources),
PUBLIC_RESOURCES, PUBLIC_RESOURCES))))
-def test_user_resources(fxtr_group_user_roles, user, expected):
+def test_user_resources(fxtr_resource_user_roles, user, expected):
"""
GIVEN: some resources in the database
WHEN: a particular user's resources are requested
THEN: list only the resources for which the user can access
"""
- conn, *_others = fxtr_group_user_roles
+ conn, *_others = fxtr_resource_user_roles
assert sorted(
{res.resource_id: res for res in user_resources(conn, user)
}.values(), key=sort_key_resources) == expected
diff --git a/tests/unit/auth/test_resources_roles.py b/tests/unit/auth/test_resources_roles.py
new file mode 100644
index 0000000..e43f25c
--- /dev/null
+++ b/tests/unit/auth/test_resources_roles.py
@@ -0,0 +1,90 @@
+"""Tests for roles for a specific resource."""
+from uuid import UUID
+
+import pytest
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.roles.models import Role, create_role
+from gn_auth.auth.authorisation.resources.groups.models import (
+ GroupRole,
+ create_group_role)
+
+from tests.unit.auth import conftest
+
+
+GROUP = conftest.TEST_GROUP_01
+PRIVILEGES = (
+ Privilege("group:resource:view-resource",
+ "view a resource and use it in computations"),
+ Privilege("group:resource:edit-resource", "edit/update a resource"))
+
+
+@pytest.mark.skip("Keep as placeholder until we implement test for creating "
+ "a resource role.")
+@pytest.mark.unit_test
+@pytest.mark.parametrize(
+ "user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
+ GroupRole(
+ UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
+ GROUP,
+ Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"),
+ "ResourceEditor", True, PRIVILEGES)),))))
+def test_create_group_role(mocker, fxtr_users_in_group, fxtr_oauth2_clients, user, expected):
+ """
+ GIVEN: an authenticated user
+ WHEN: the user attempts to create a role, attached to a group
+ THEN: verify they are only able to create the role if they have the
+ appropriate privileges and that the role is attached to the given group
+ """
+ _conn, clients = fxtr_oauth2_clients
+ mocker.patch("gn_auth.auth.authorisation.resources.groups.models.uuid4", conftest.uuid_fn)
+ mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
+ conn, _group, _users = fxtr_users_in_group
+ with db.cursor(conn) as cursor:
+ assert create_group_role(
+ conn, GROUP, "ResourceEditor", PRIVILEGES) == expected
+ # cleanup
+ cursor.execute(
+ ("DELETE FROM group_roles "
+ "WHERE group_role_id=? AND group_id=? AND role_id=?"),
+ (str(conftest.uuid_fn()), str(GROUP.group_id), str(conftest.uuid_fn())))
+
+
+@pytest.mark.skip(
+ "This needs to be replaced by tests for creation of resource roles.")
+@pytest.mark.unit_test
+@pytest.mark.parametrize(
+ "user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
+ Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_role",
+ True, PRIVILEGES),))))
+def test_create_role(# pylint: disable=[too-many-arguments, too-many-positional-arguments, unused-argument]
+ fxtr_app,
+ auth_testdb_path,
+ mocker,
+ fxtr_users,
+ fxtr_oauth2_clients,
+ user,
+ expected
+):
+ """
+ GIVEN: an authenticated user
+ WHEN: the user attempts to create a role
+ THEN: verify they are only able to create the role if they have the
+ appropriate privileges
+ """
+ _conn, clients = fxtr_oauth2_clients
+ mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
+ with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor:
+ the_role = create_role(cursor, "a_test_role", PRIVILEGES)
+ assert the_role == expected
diff --git a/tests/unit/auth/test_roles.py b/tests/unit/auth/test_roles.py
index 00148a0..43d84e4 100644
--- a/tests/unit/auth/test_roles.py
+++ b/tests/unit/auth/test_roles.py
@@ -1,5 +1,5 @@
"""Test functions dealing with group management."""
-import uuid
+from uuid import UUID
import pytest
@@ -21,136 +21,288 @@ PRIVILEGES = (
"view a resource and use it in computations"),
Privilege("group:resource:edit-resource", "edit/update a resource"))
-@pytest.mark.unit_test
-@pytest.mark.parametrize(
- "user,expected", tuple(zip(conftest.TEST_USERS[0:1], (
- Role(uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_role",
- True, PRIVILEGES),))))
-def test_create_role(# pylint: disable=[too-many-arguments]
- fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument]
- """
- GIVEN: an authenticated user
- WHEN: the user attempts to create a role
- THEN: verify they are only able to create the role if they have the
- appropriate privileges
- """
- mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
- with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor:
- the_role = create_role(cursor, "a_test_role", PRIVILEGES)
- assert the_role == expected
@pytest.mark.unit_test
@pytest.mark.parametrize(
"user,expected", tuple(zip(conftest.TEST_USERS[1:], (
create_role_failure, create_role_failure, create_role_failure))))
-def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments]
- fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument]
+def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments, unused-argument, too-many-positional-arguments]
+ fxtr_app,
+ auth_testdb_path,
+ mocker,
+ fxtr_users,
+ fxtr_oauth2_clients,
+ user,
+ expected
+):
"""
GIVEN: an authenticated user
WHEN: the user attempts to create a role
THEN: verify they are only able to create the role if they have the
appropriate privileges
"""
+ _conn, clients = fxtr_oauth2_clients
mocker.patch("gn_auth.auth.authorisation.roles.models.uuid4", conftest.uuid_fn)
- mocker.patch("gn_auth.auth.authorisation.checks.require_oauth.acquire",
- conftest.get_tokeniser(user))
+ mocker.patch(
+ "gn_auth.auth.authorisation.checks.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(client for client in clients if client.user == user)[0]))
with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor:
with pytest.raises(AuthorisationError):
create_role(cursor, "a_test_role", PRIVILEGES)
+
+# This might still be incomplete, especially regarding resource roles.
@pytest.mark.unit_test
@pytest.mark.parametrize(
"user,expected",
(zip(TEST_USERS,
- (({"resource_id": uuid.UUID("38d1807d-105f-44a7-8327-7e2d973b6d8d"),
- "user_id": uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
+ (({"resource_id": UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
"roles": (Role(
- role_id=uuid.UUID('a0e67630-d502-4b9f-b23f-6805d0f30e30'),
- role_name='group-leader', user_editable=False,
+ role_id=UUID("89819f84-6346-488b-8955-86062e9eedb7"),
+ role_name="resource_editor",
+ user_editable=True,
privileges=(
Privilege(
- privilege_id='group:resource:create-resource',
- privilege_description='Create a resource object'),
+ privilege_id="group:resource:edit-resource",
+ privilege_description="edit/update a resource"),
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"))),)},
+ {"resource_id": UUID("26ad1668-29f5-439d-b905-84d551f85955"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
+ "roles": (
+ Role(
+ role_id=UUID("89819f84-6346-488b-8955-86062e9eedb7"),
+ role_name="resource_editor",
+ user_editable=True,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:edit-resource",
+ privilege_description="edit/update a resource"),
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"))),
+ Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description=(
+ "view a resource and use it in computations")),)))},
+ {"resource_id": UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
+ "roles": (Role(
+ role_id=UUID("89819f84-6346-488b-8955-86062e9eedb7"),
+ role_name="resource_editor",
+ user_editable=True,
+ privileges=(
Privilege(
- privilege_id='group:resource:delete-resource',
- privilege_description='Delete a resource'),
+ privilege_id="group:resource:edit-resource",
+ privilege_description="edit/update a resource"),
Privilege(
- privilege_id='group:resource:edit-resource',
- privilege_description='edit/update a resource'),
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"))),)},
+ {"resource_id": UUID("38d1807d-105f-44a7-8327-7e2d973b6d8d"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
+ "roles": (Role(
+ role_id=UUID("a0e67630-d502-4b9f-b23f-6805d0f30e30"),
+ role_name="group-leader",
+ user_editable=False,
+ privileges=(
Privilege(
- privilege_id='group:resource:view-resource',
- privilege_description=(
- 'view a resource and use it in computations')),
+ "group:data:link-to-group",
+ "Allow linking data to only one specific group."),
+
Privilege(
- privilege_id='group:role:create-role',
- privilege_description='Create a new role'),
+ privilege_id="group:resource:create-resource",
+ privilege_description="Create a resource object"),
Privilege(
- privilege_id='group:role:delete-role',
- privilege_description='Delete an existing role'),
+ privilege_id="group:resource:delete-resource",
+ privilege_description="Delete a resource"),
Privilege(
- privilege_id='group:role:edit-role',
- privilege_description='edit/update an existing role'),
+ privilege_id="group:resource:edit-resource",
+ privilege_description="edit/update a resource"),
Privilege(
- privilege_id='group:user:add-group-member',
- privilege_description='Add a user to a group'),
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),
Privilege(
- privilege_id='group:user:assign-role',
- privilege_description=(
- 'Assign a role to an existing user')),
+ privilege_id="group:user:add-group-member",
+ privilege_description="Add a user to a group"),
Privilege(
- privilege_id='group:user:remove-group-member',
- privilege_description='Remove a user from a group'),
+ privilege_id="group:user:remove-group-member",
+ privilege_description="Remove a user from a group"),
Privilege(
- privilege_id='system:group:delete-group',
- privilege_description='Delete a group'),
+ privilege_id="system:group:delete-group",
+ privilege_description="Delete a group"),
Privilege(
- privilege_id='system:group:edit-group',
- privilege_description='Edit the details of a group'),
+ privilege_id="system:group:edit-group",
+ privilege_description="Edit the details of a group"),
Privilege(
- privilege_id='system:group:transfer-group-leader',
+ privilege_id="system:group:transfer-group-leader",
privilege_description=(
- 'Transfer leadership of the group to some other '
- 'member')),
+ "Transfer leadership of the group to some other member")),
Privilege(
- privilege_id='system:group:view-group',
- privilege_description='View the details of a group'),
+ privilege_id="system:group:view-group",
+ privilege_description="View the details of a group"),
Privilege(
- privilege_id='system:user:list',
- privilege_description='List users in the system'))),)
- },
- {
- "resource_id": uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
- "user_id": uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
- "roles": (Role(
- role_id=uuid.UUID("ade7e6b0-ba9c-4b51-87d0-2af7fe39a347"),
- role_name="group-creator",
- user_editable=False,
- privileges=(
- Privilege(
- privilege_id="system:group:create-group",
- privilege_description="Create a group"),)),)}),
- ({"resource_id": uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
- "user_id": uuid.UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
+ privilege_id="system:user:list",
+ privilege_description="List users in the system"))),)},
+ {"resource_id": UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
"roles": (Role(
- role_id=uuid.UUID('89819f84-6346-488b-8955-86062e9eedb7'),
- role_name='resource_editor',
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)},
+ {"resource_id": UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
+ "user_id": UUID("ecb52977-3004-469e-9428-2a1856725c7f"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)}),
+ ({"resource_id": UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
+ "user_id": UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
+ "roles": (Role(
+ role_id=UUID("89819f84-6346-488b-8955-86062e9eedb7"),
+ role_name="resource_editor",
user_editable=True,
privileges=(
Privilege(
- privilege_id='group:resource:edit-resource',
- privilege_description='edit/update a resource'),
+ privilege_id="group:resource:edit-resource",
+ privilege_description="edit/update a resource"),
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"))),)
+ },
+ {"resource_id": UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
+ "user_id": UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ },
+ {"resource_id": UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
+ "user_id": UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ },
+ {"resource_id": UUID("26ad1668-29f5-439d-b905-84d551f85955"),
+ "user_id": UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ }),
+ ({"resource_id": UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
+ "user_id": UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ },
+ {"resource_id": UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
+ "user_id": UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ },
+ {"resource_id": UUID("26ad1668-29f5-439d-b905-84d551f85955"),
+ "user_id": UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
Privilege(
- privilege_id='group:resource:view-resource',
- privilege_description='view a resource and use it in computations'))),)},),
- tuple(),
- tuple()))))
-def test_user_roles(fxtr_group_user_roles, user, expected):
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ }),
+ ({"resource_id": UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
+ "user_id": UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
+ "roles": (
+ Role(
+ role_id=UUID("ade7e6b0-ba9c-4b51-87d0-2af7fe39a347"),
+ role_name="group-creator",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="system:group:create-group",
+ privilege_description="Create a group"),)),
+ Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)))
+ },
+ {"resource_id": UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
+ "user_id": UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description="view a resource and use it in computations"),)),)
+ },
+ {"resource_id": UUID("26ad1668-29f5-439d-b905-84d551f85955"),
+ "user_id": UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
+ "roles": (Role(
+ role_id=UUID("fd88bfed-d869-4969-87f2-67c4e8446ecb"),
+ role_name="public-view",
+ user_editable=False,
+ privileges=(
+ Privilege(
+ privilege_id="group:resource:view-resource",
+ privilege_description=(
+ "view a resource and use it in computations")),)),)})))))
+def test_user_roles(
+ fxtr_resource_user_roles,
+ user,
+ expected
+):
"""
GIVEN: an authenticated user
WHEN: we request the user's privileges
THEN: return **ALL** the privileges attached to the user
"""
- conn, *_others = fxtr_group_user_roles
+ conn, *_others = fxtr_resource_user_roles
assert user_roles(conn, user) == expected
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index dcf4003..53ee062 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -8,6 +8,17 @@ import pytest
from gn_auth import create_app
+def setup_secrets(rootdir: Path) -> Path:
+ """Setup secrets directory and file."""
+ secretsfile = Path(rootdir).joinpath("secrets/secrets.py")
+ secretsfile.parent.mkdir(exist_ok=True)
+ with open(secretsfile, "w", encoding="utf8") as outfile:
+ outfile.write(
+ 'SECRET_KEY="qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk"')
+
+ return secretsfile
+
+
@pytest.fixture(scope="session")
def fxtr_app():
"""Fixture: setup the test app"""
@@ -22,8 +33,8 @@ def fxtr_app():
app = create_app({
"TESTING": True,
"AUTH_DB": testdb,
+ "GN_AUTH_SECRETS": str(setup_secrets(testdir)),
"OAUTH2_ACCESS_TOKEN_GENERATOR": "tests.unit.auth.test_token.gen_token",
- "SECRET_KEY": "qQIrgiK29kXZU6v8D09y4uw_sk8I4cqgNZniYUrRoUk",
"UPLOADS_DIR": testuploadsdir,
"SSL_PRIVATE_KEY": f"{testsroot}/test-ssl-private-key.pem",
"CLIENTS_SSL_PUBLIC_KEYS_DIR": f"{testsroot}/test-public-keys-dir"