about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.guix/modules/gn-auth.scm2
-rw-r--r--gn_auth/auth/authorisation/data/views.py99
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py11
-rw-r--r--gn_auth/auth/authorisation/users/models.py38
-rw-r--r--gn_auth/wsgi.py374
5 files changed, 478 insertions, 46 deletions
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm
index 0d9cbc9..190f695 100644
--- a/.guix/modules/gn-auth.scm
+++ b/.guix/modules/gn-auth.scm
@@ -34,7 +34,7 @@
         #~(modify-phases #$phases
             (add-before 'build 'pylint
               (lambda _
-                (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts")))
+                (invoke "pylint" "tests" "gn_auth")))
             (add-after 'pylint 'mypy
               (lambda _
                 (invoke "mypy" ".")))))))
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 584b239..228d95f 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -104,10 +104,22 @@ def authorisation() -> Response:
                         authconn, _dset_traits["ProbeSet"]))
             for _rrow in _rtypes
         }
-        if len(_all_resources.keys()) == 0:
+        if (len(_all_resources.keys()) == 0 and
+                len(_dset_traits.get("Temp", tuple())) == 0):
             raise NotFoundError(
                 "No resource(s) found for specified trait(s). Do(es) the "
                 "trait(s) actually exist?")
+
+        # Handle Temp traits specially - they should be public/anonymous resources
+        if len(_dset_traits.get("Temp", tuple())) > 0:
+            # Create a synthetic public resource for Temp traits
+            # Use a predictable ID to identify synthetic temp resources
+            temp_resource_id = "gn-auth-temp-traits"
+            _all_resources[temp_resource_id] = {
+                "resource_id": temp_resource_id,
+                "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"])
+            }
+
         _resource_ids = tuple(_all_resources.keys())
 
 
@@ -125,42 +137,55 @@ def authorisation() -> Response:
             }
 
         _paramstr = ", ".join(["?"] * len(_resource_ids))
-        try:
-            with require_oauth.acquire("profile group resource") as _token:
-                user = _token.user
-                cursor.execute(
-                    "SELECT ur.resource_id, r.role_id, rp.privilege_id "
-                    "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 "
-                    "WHERE ur.user_id = ? "
-                    f"AND ur.resource_id IN ({_paramstr})",
-                    (str(user.user_id),) + _resource_ids
-                )
-                _privileges_by_resource: dict[str, tuple[str, ...]] = reduce(
-                    lambda acc, curr: {
-                        **acc,
-                        curr["resource_id"]: (
-                            acc.get(curr["resource_id"], tuple())
-                            + (curr["privilege_id"],))
-                    },
-                    cursor.fetchall(),
-                    {})
-        except _HTTPException as exc:
-            err_msg = json.loads(exc.body)
-            if err_msg["error"] == "missing_authorization":
-                cursor.execute(
-                    "SELECT rsc.resource_id "
-                    "FROM resources AS rsc "
-                    "WHERE rsc.public = '1' "
-                    f"AND rsc.resource_id IN ({_paramstr}) ",
-                    _resource_ids)
-                _privileges_by_resource = {
-                    row["resource_id"]: ('group:resource:view-resource',)
-                    for row in cursor.fetchall()
-                }
-            else:
-                raise exc from None
+        _privileges_by_resource: dict[str, tuple[str, ...]] = {}
+
+        # Separate synthetic temp resources from real resources
+        temp_resource_id = "gn-auth-temp-traits"
+        real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id)
+
+        # Query privileges only for real resources
+        if len(real_resource_ids) > 0:
+            real_paramstr = ", ".join(["?"] * len(real_resource_ids))
+            try:
+                with require_oauth.acquire("profile group resource") as _token:
+                    user = _token.user
+                    cursor.execute(
+                        "SELECT ur.resource_id, r.role_id, rp.privilege_id "
+                        "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 "
+                        "WHERE ur.user_id = ? "
+                        f"AND ur.resource_id IN ({real_paramstr})",
+                        (str(user.user_id),) + real_resource_ids
+                    )
+                    _privileges_by_resource = reduce(
+                        lambda acc, curr: {
+                            **acc,
+                            curr["resource_id"]: (
+                                acc.get(curr["resource_id"], tuple())
+                                + (curr["privilege_id"],))
+                        },
+                        cursor.fetchall(),
+                        {})
+            except _HTTPException as exc:
+                err_msg = json.loads(exc.body)
+                if err_msg["error"] == "missing_authorization":
+                    cursor.execute(
+                        "SELECT rsc.resource_id "
+                        "FROM resources AS rsc "
+                        "WHERE rsc.public = '1' "
+                        f"AND rsc.resource_id IN ({real_paramstr}) ",
+                        real_resource_ids)
+                    _privileges_by_resource = {
+                        row["resource_id"]: ('group:resource:view-resource',)
+                        for row in cursor.fetchall()
+                    }
+                else:
+                    raise exc from None
+
+        # Temp resources are always publicly viewable
+        if temp_resource_id in _resource_ids:
+            _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',)
 
         return jsonify({
             "authorisation": [{
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 3d68932..0594864 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -4,6 +4,7 @@ import warnings
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User
 from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
+from gn_auth.auth.authorisation.resources.system.models import system_resource
 
 
 def sysadmin_role(conn: db.DbConnection) -> Role:
@@ -28,14 +29,14 @@ def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
     cursor.execute(
             "SELECT * FROM roles WHERE role_name='system-administrator'")
     admin_role = cursor.fetchone()
-    cursor.execute("SELECT resources.resource_id FROM resources")
-    cursor.executemany(
+    sysresource = system_resource(cursor)
+    cursor.execute(
         "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
-        tuple({
+        {
             "user_id": str(user.user_id),
             "role_id": admin_role["role_id"],
-            "resource_id": resource_id
-        } for resource_id in cursor.fetchall()))
+            "resource_id": str(sysresource.resource_id)
+        })
     return user
 
 
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index d30bfd0..ab7a980 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,5 +1,6 @@
 """Functions for acting on users."""
 import uuid
+import warnings
 from functools import reduce
 from datetime import datetime, timedelta
 
@@ -128,3 +129,40 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu
             (str(user.user_id),))
         return __build_resource_roles__(
             (dict(row) for row in cursor.fetchall()))
+
+
+def delete_users_by_id(
+        conn: db.DbConnection,
+        user_ids: tuple[uuid.UUID, ...]
+) -> int:
+    """Delete users unconditionally by ID, removing all dependent data.
+
+    Unlike the HTTP endpoint, this bypasses all policy checks — users are
+    deleted regardless of their roles or group memberships. Returns the
+    number of users removed from the users table.
+    """
+    warnings.warn(
+        (f"Running dangerous function `{__name__}.delete_users_by_id`. "
+         "Do ensure that is what you actually want."),
+        category=RuntimeWarning)
+    if not user_ids:
+        return 0
+    _ids = tuple(str(uid) for uid in user_ids)
+    _paramstr = ", ".join(["?"] * len(_ids))
+    _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"),
+    )
+    with db.cursor(conn) as cursor:
+        for table, col in _dependent_tables:
+            cursor.execute(
+                f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids)
+        cursor.execute(
+            f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids)
+        return cursor.rowcount
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index f2f17f1..a5af37e 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -1,10 +1,13 @@
 """Main entry point for project"""
+import os
+import re
+import secrets
 import sys
 import uuid
 import json
 from math import ceil
 from pathlib import Path
-from datetime import datetime
+from datetime import datetime, timezone
 
 import click
 from yoyo import get_backend, read_migrations
@@ -14,8 +17,15 @@ from gn_auth import create_app
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.errors import NotFoundError
-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 gn_auth.auth.authentication.users import (
+    user_by_id, hash_password, save_user, set_user_password)
+from gn_auth.auth.authorisation.roles.models import assign_default_roles
+from gn_auth.auth.authorisation.users.admin.models import (
+    make_sys_admin, grant_sysadmin_role)
+from gn_auth.auth.authorisation.users.models import delete_users_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+    OAuth2Client, save_client, delete_client,
+    client as oauth2_client_by_id)
 from gn_auth.scripts import register_sys_admin as rsysadm# type: ignore[import]
 
 
@@ -126,6 +136,364 @@ def register_admin():
     """Register the administrator."""
     rsysadm.register_admin(Path(app.config["AUTH_DB"]))
 
+
+_VALID_ROLES_ = ("system-admin", "none")
+
+_TEST_EMAIL_DOMAIN_ = "regression-tests.genenetwork.org"
+
+
+def __normalise_name_for_email__(name: str) -> str:
+    """Lowercase and strip non-alphanumeric characters for use in an email."""
+    return re.sub(r"[^a-z0-9]", "", name.lower())
+
+
+def __create_one_user__(cursor, name: str, email: str, password: str, role: str) -> dict:
+    """Create a single user in the DB and return their credential record."""
+    user = save_user(cursor, email, name, verified=True)
+    set_user_password(cursor, user, password)
+    assign_default_roles(cursor, user)
+    if role == "system-admin":
+        grant_sysadmin_role(cursor, user)
+    return {
+        "user_id": str(user.user_id),
+        "name": user.name,
+        "email": user.email,
+        "password": password,
+        "role": role,
+    }
+
+
+def __parse_user_spec__(spec: str) -> dict:
+    """Parse 'key=value,key=value,...' into a dict."""
+    result = {}
+    for part in spec.split(","):
+        key, _, value = part.partition("=")
+        if key.strip():
+            result[key.strip()] = value.strip()
+    return result
+
+
+def __write_output__(data: dict, output_path) -> None:
+    """Write JSON data to a file with 0600 permissions, or stdout."""
+    text = json.dumps(data, indent=2)
+    if output_path is None:
+        print(text)
+        return
+    fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+    with os.fdopen(fd, "w") as outfile:
+        outfile.write(text)
+
+
+@app.cli.command()
+@click.option("--user", "user_specs", multiple=True,
+              help='User spec: "name=...,email=...,password=...,role=..."')
+@click.option("--output", "output_path", type=click.Path(), default=None,
+              help="Write credentials as JSON to this file (default: stdout)")
+def create_users(user_specs, output_path):
+    """Create one or more users with specified credentials and roles.
+
+    Each --user option takes a comma-separated key=value string with the
+    following keys: name, email, password, role.
+
+    Valid roles: system-admin, none.
+    """
+    if not user_specs:
+        print("No users specified.", file=sys.stderr)
+        sys.exit(1)
+
+    records = []
+    with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+        for spec_str in user_specs:
+            spec = __parse_user_spec__(spec_str)
+            name = spec.get("name", "").strip()
+            email = spec.get("email", "").strip()
+            password = spec.get("password", "").strip()
+            role = spec.get("role", "none").strip()
+
+            if not name:
+                print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if not email:
+                print(f"Missing 'email' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if not password:
+                print(f"Missing 'password' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if role not in _VALID_ROLES_:
+                print(
+                    f"Invalid role {role!r} in spec: {spec_str!r}. "
+                    f"Valid roles: {_VALID_ROLES_}",
+                    file=sys.stderr)
+                sys.exit(1)
+
+            records.append(
+                __create_one_user__(cursor, name, email, password, role))
+
+    __write_output__({"users": records}, output_path)
+
+
+@app.cli.command()
+@click.option("--user-id", "user_ids", multiple=True, type=click.UUID,
+              help="UUID of a user to delete (repeatable)")
+def delete_users(user_ids):
+    """Delete one or more users by ID, bypassing policy checks.
+
+    Removes users unconditionally regardless of their roles or group
+    memberships. Use with care — intended for test teardown and administration.
+    """
+    if not user_ids:
+        print("No user IDs specified.", file=sys.stderr)
+        sys.exit(1)
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        deleted = delete_users_by_id(conn, tuple(user_ids))
+        print(f"Deleted {deleted} user(s).")
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+              help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--user", "user_specs", multiple=True,
+              help='User spec: "name=...,role=..."')
+@click.option("--output", "output_path", required=True, type=click.Path(),
+              help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_users(session_timestamp, user_specs, output_path):
+    """Create ephemeral test users with auto-generated email and password.
+
+    Each --user option takes a comma-separated key=value string with the
+    following keys: name, role.
+
+    Email: <normalised-name><timestamp>@regression-tests.genenetwork.org
+    Password: randomly generated.
+
+    Output is written with 0600 permissions. Valid roles: system-admin, none.
+    """
+    if not user_specs:
+        print("No users specified.", file=sys.stderr)
+        sys.exit(1)
+
+    records = []
+    with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+        for spec_str in user_specs:
+            spec = __parse_user_spec__(spec_str)
+            name = spec.get("name", "").strip()
+            role = spec.get("role", "none").strip()
+
+            if not name:
+                print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if role not in _VALID_ROLES_:
+                print(
+                    f"Invalid role {role!r} in spec: {spec_str!r}. "
+                    f"Valid roles: {_VALID_ROLES_}",
+                    file=sys.stderr)
+                sys.exit(1)
+
+            email = (f"{__normalise_name_for_email__(name)}"
+                     f"{session_timestamp}@{_TEST_EMAIL_DOMAIN_}")
+            password = secrets.token_urlsafe(32)
+
+            records.append(
+                __create_one_user__(cursor, name, email, password, role))
+
+    __write_output__(
+        {"session_timestamp": session_timestamp, "users": records},
+        output_path)
+
+
+_DEFAULT_GRANT_TYPES_ = (
+    "password",
+    "authorization_code",
+    "refresh_token",
+    "urn:ietf:params:oauth:grant-type:jwt-bearer",
+)
+
+_DEFAULT_SCOPES_ = (
+    "profile", "group", "role", "resource",
+    "register-client", "user", "masquerade",
+    "migrate-data", "introspect",
+)
+
+
+def __create_one_client__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+        conn,
+        client_name: str,
+        owner_user,
+        redirect_uris: tuple,
+        scopes: tuple = _DEFAULT_SCOPES_,
+        grant_types: tuple = _DEFAULT_GRANT_TYPES_,
+        jwks_uri: str = "",
+) -> dict:
+    """Create a single OAuth2 client and return its credential record."""
+    raw_secret = secrets.token_urlsafe(32)
+    the_client = OAuth2Client(
+        client_id=uuid.uuid4(),
+        client_secret=hash_password(raw_secret),
+        client_id_issued_at=datetime.now(tz=timezone.utc),
+        client_secret_expires_at=datetime.fromtimestamp(0),
+        client_metadata={
+            "client_name": client_name,
+            "token_endpoint_auth_method": [
+                "client_secret_post", "client_secret_basic"],
+            "client_type": "confidential",
+            "grant_types": list(grant_types),
+            "default_redirect_uri": redirect_uris[0] if redirect_uris else "",
+            "redirect_uris": list(redirect_uris),
+            "response_type": ["code", "token"],
+            "scope": list(scopes),
+            "public-jwks-uri": jwks_uri,
+        },
+        user=owner_user)
+    save_client(conn, the_client)
+    return {
+        "client_id": str(the_client.client_id),
+        "client_secret": raw_secret,
+        "client_name": client_name,
+    }
+
+
+@app.cli.command()
+@click.option("--name", "client_name", required=True,
+              help="Human-readable name for the OAuth2 client")
+@click.option("--owner-id", required=True, type=click.UUID,
+              help="UUID of the user who owns this client")
+@click.option("--redirect-uri", "redirect_uris", multiple=True,
+              help="Allowed redirect URI (repeatable)")
+@click.option("--scope", "scopes", multiple=True,
+              default=_DEFAULT_SCOPES_, show_default=False,
+              help="OAuth2 scope (repeatable; defaults to full scope set)")
+@click.option("--grant-type", "grant_types", multiple=True,
+              default=_DEFAULT_GRANT_TYPES_, show_default=False,
+              help="Grant type (repeatable; defaults to all standard types)")
+@click.option("--jwks-uri", default="",
+              help="URI to the client's public JWKS (optional)")
+@click.option("--output", "output_path", type=click.Path(), default=None,
+              help="Write credentials as JSON to this file (default: stdout)")
+def create_oauth2_client(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+        client_name,
+        owner_id,
+        redirect_uris,
+        scopes,
+        grant_types,
+        jwks_uri,
+        output_path
+):
+    """Create an OAuth2 client with specified parameters.
+
+    Scopes and grant types default to the full standard set if not provided.
+    """
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        try:
+            owner = user_by_id(conn, owner_id)
+        except NotFoundError:
+            print(f"No user found with ID {owner_id}", file=sys.stderr)
+            sys.exit(1)
+        record = __create_one_client__(
+            conn, client_name, owner, redirect_uris, scopes, grant_types,
+            jwks_uri)
+
+    __write_output__({"client": record}, output_path)
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+              help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--users-file", required=True, type=click.Path(exists=True),
+              help="Credentials file produced by create-test-users")
+@click.option("--owner-role", default="system-admin", show_default=True,
+              help="Role of the user in users-file to assign as client owner")
+@click.option("--output", "output_path", required=True, type=click.Path(),
+              help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_oauth2_client(session_timestamp, users_file, owner_role,
+                              output_path):
+    """Create an ephemeral OAuth2 client for a test session.
+
+    Reads the credentials file produced by create-test-users to find the
+    owner. Client name and secret are auto-generated using the session
+    timestamp. Output is written with 0600 permissions.
+    """
+    with open(users_file, encoding="utf8") as f:
+        users_data = json.load(f)
+
+    owner_record = next(
+        (u for u in users_data.get("users", []) if u["role"] == owner_role),
+        None)
+    if owner_record is None:
+        print(
+            f"No user with role {owner_role!r} found in {users_file}",
+            file=sys.stderr)
+        sys.exit(1)
+
+    client_name = f"gn-test-client-{session_timestamp}"
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        try:
+            owner = user_by_id(conn, uuid.UUID(owner_record["user_id"]))
+        except NotFoundError:
+            print(
+                f"Owner user {owner_record['user_id']!r} not found in DB",
+                file=sys.stderr)
+            sys.exit(1)
+        record = __create_one_client__(conn, client_name, owner, tuple())
+
+    __write_output__(
+        {"session_timestamp": session_timestamp, "client": record},
+        output_path)
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+              type=click.Path(exists=True),
+              help="Credentials file produced by create-oauth2-client or "
+                   "create-test-oauth2-client")
+def delete_oauth2_client(credentials_path):
+    """Delete an OAuth2 client using a credentials file.
+
+    Reads the client_id from the given credentials file and removes the
+    client and all associated tokens from the database.
+    """
+    with open(credentials_path, encoding="utf8") as f:
+        data = json.load(f)
+
+    client_id_str = data.get("client", {}).get("client_id")
+    if not client_id_str:
+        print("No client_id found in credentials file.", file=sys.stderr)
+        sys.exit(1)
+
+    client_id = uuid.UUID(client_id_str)
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        the_client = oauth2_client_by_id(conn, client_id)
+        if the_client.is_nothing():
+            print(f"No client found with ID {client_id}", file=sys.stderr)
+            sys.exit(1)
+        delete_client(conn, the_client.value)
+        print(f"Deleted OAuth2 client {client_id}.")
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+              type=click.Path(exists=True),
+              help="Credentials file produced by create-test-users")
+def delete_test_users(credentials_path):
+    """Delete ephemeral test users using a credentials file.
+
+    Reads the credentials file produced by create-test-users and deletes
+    all listed users unconditionally, bypassing policy checks. Intended
+    for CI test teardown.
+    """
+    with open(credentials_path, encoding="utf8") as f:
+        data = json.load(f)
+
+    user_ids = tuple(
+        uuid.UUID(u["user_id"]) for u in data.get("users", []))
+    if not user_ids:
+        print("No users found in credentials file.", file=sys.stderr)
+        sys.exit(1)
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        deleted = delete_users_by_id(conn, user_ids)
+        print(f"Deleted {deleted} user(s).")
+
 ##### END: CLI Commands #####
 
 if __name__ == '__main__':