diff --git a/lib/pbench/client/__init__.py b/lib/pbench/client/__init__.py index 9121aac3f2..6015509644 100644 --- a/lib/pbench/client/__init__.py +++ b/lib/pbench/client/__init__.py @@ -48,6 +48,7 @@ class API(Enum): DATASETS_SEARCH = "datasets_search" DATASETS_VALUES = "datasets_values" ENDPOINTS = "endpoints" + KEY = "key" SERVER_AUDIT = "server_audit" SERVER_SETTINGS = "server_settings" UPLOAD = "upload" diff --git a/lib/pbench/server/api/__init__.py b/lib/pbench/server/api/__init__.py index 467fe7c082..029c65dfcd 100644 --- a/lib/pbench/server/api/__init__.py +++ b/lib/pbench/server/api/__init__.py @@ -13,6 +13,7 @@ from pbench.common.exceptions import ConfigFileNotSpecified from pbench.common.logger import get_pbench_logger from pbench.server import PbenchServerConfig +from pbench.server.api.resources.api_key import APIKeyManage from pbench.server.api.resources.datasets_inventory import DatasetsInventory from pbench.server.api.resources.datasets_list import DatasetsList from pbench.server.api.resources.datasets_metadata import DatasetsMetadata @@ -123,6 +124,12 @@ def register_endpoints(api: Api, app: Flask, config: PbenchServerConfig): endpoint="endpoints", resource_class_args=(config,), ) + api.add_resource( + APIKeyManage, + f"{base_uri}/key", + endpoint="key", + resource_class_args=(config,), + ) api.add_resource( ServerAudit, f"{base_uri}/server/audit", diff --git a/lib/pbench/server/api/resources/api_key.py b/lib/pbench/server/api/resources/api_key.py new file mode 100644 index 0000000000..a7eddbab3a --- /dev/null +++ b/lib/pbench/server/api/resources/api_key.py @@ -0,0 +1,77 @@ +from http import HTTPStatus + +from flask import jsonify +from flask.wrappers import Request, Response + +from pbench.server import PbenchServerConfig +from pbench.server.api.resources import ( + APIAbort, + ApiAuthorizationType, + ApiBase, + ApiContext, + APIInternalError, + ApiMethod, + ApiParams, + ApiSchema, +) +import pbench.server.auth.auth as Auth +from pbench.server.database.models.api_keys import APIKey, DuplicateApiKey +from pbench.server.database.models.audit import AuditType, OperationCode + + +class APIKeyManage(ApiBase): + def __init__(self, config: PbenchServerConfig): + super().__init__( + config, + ApiSchema( + ApiMethod.POST, + OperationCode.CREATE, + audit_type=AuditType.API_KEY, + audit_name="apikey", + authorization=ApiAuthorizationType.NONE, + ), + ) + + def _post( + self, params: ApiParams, request: Request, context: ApiContext + ) -> Response: + """ + Post request for generating a new persistent API key. + + Required headers include + + Content-Type: application/json + Accept: application/json + + Returns: + Success: 201 with api_key + + Raises: + APIAbort, reporting "UNAUTHORIZED" + APIInternalError, reporting the failure message + """ + user = Auth.token_auth.current_user() + + if not user: + raise APIAbort( + HTTPStatus.UNAUTHORIZED, + "User provided access_token is invalid or expired", + ) + try: + new_key = APIKey.generate_api_key(user) + except Exception as e: + raise APIInternalError(str(e)) from e + + try: + key = APIKey(api_key=new_key, user=user) + key.add() + status = HTTPStatus.CREATED + except DuplicateApiKey: + status = HTTPStatus.OK + except Exception as e: + raise APIInternalError(str(e)) from e + + context["auditing"]["attributes"] = {"key": new_key} + response = jsonify({"api_key": new_key}) + response.status_code = status + return response diff --git a/lib/pbench/server/auth/auth.py b/lib/pbench/server/auth/auth.py index 79cd2a7477..3e178514ae 100644 --- a/lib/pbench/server/auth/auth.py +++ b/lib/pbench/server/auth/auth.py @@ -1,21 +1,15 @@ -from datetime import datetime, timedelta, timezone -import enum from http import HTTPStatus -from typing import Optional, Tuple +from typing import Optional from flask import current_app, Flask, request from flask_httpauth import HTTPTokenAuth from flask_restful import abort -import jwt from pbench.server import PbenchServerConfig from pbench.server.auth import OpenIDClient, OpenIDTokenInvalid -from pbench.server.database.models.auth_tokens import AuthToken +from pbench.server.database.models.api_keys import APIKey from pbench.server.database.models.users import User -# Module private constants -_TOKEN_ALG_INT = "HS256" - # Module public token_auth = HTTPTokenAuth("Bearer") oidc_client: OpenIDClient = None @@ -53,43 +47,6 @@ def get_current_user_id() -> Optional[str]: return str(user.id) if user else None -def encode_auth_token(time_delta: timedelta, user_id: int) -> Tuple[str, datetime]: - """Generates an authorization token for an internal user ID. - - Args: - time_delta : Token lifetime - user_id : Authorized user's internal ID - - Returns: - A tuple containing the JWT token string and the absolute expiration time - """ - current_utc = datetime.now(timezone.utc) - expiration = current_utc + time_delta - payload = { - "iat": current_utc, - "exp": expiration, - "sub": user_id, - } - try: - auth_token = jwt.encode( - payload, current_app.secret_key, algorithm=_TOKEN_ALG_INT - ) - except ( - jwt.InvalidIssuer, - jwt.InvalidIssuedAtError, - jwt.InvalidAlgorithmError, - jwt.PyJWTError, - ): - current_app.logger.exception( - "Could not encode the JWT auth token for user ID: {}", user_id - ) - abort( - HTTPStatus.INTERNAL_SERVER_ERROR, - message="INTERNAL ERROR", - ) - return auth_token, expiration - - def get_auth_token() -> str: """Get the authorization token from the current request. @@ -142,10 +99,7 @@ def verify_auth(auth_token: str) -> Optional[User]: return None user = None try: - if oidc_client is not None: - user = verify_auth_oidc(auth_token) - else: - user = verify_auth_internal(auth_token) + user = verify_auth_oidc(auth_token) except Exception as e: current_app.logger.exception( "Unexpected exception occurred while verifying the auth token {!r}: {}", @@ -155,65 +109,31 @@ def verify_auth(auth_token: str) -> Optional[User]: return user -class TokenState(enum.Enum): - """The state of a token once decoded.""" - - INVALID = enum.auto() - EXPIRED = enum.auto() - VERIFIED = enum.auto() - - -def verify_internal_token(auth_token: str) -> TokenState: - """Returns a TokenState depending on the state of the given token after - being decoded. - """ - try: - jwt.decode( - auth_token, - current_app.secret_key, - algorithms=_TOKEN_ALG_INT, - options={ - "verify_signature": True, - "verify_aud": True, - "verify_exp": True, - }, - ) - except (jwt.InvalidSignatureError, jwt.DecodeError): - state = TokenState.INVALID - except jwt.ExpiredSignatureError: - state = TokenState.EXPIRED - else: - state = TokenState.VERIFIED - return state - - -def verify_auth_internal(auth_token_s: str) -> Optional[User]: - """Validates the auth token of the current request. - - Tries to validate the token as if it was generated by the Pbench server for - an internal user. +def verify_auth_api_key(api_key: str) -> Optional[User]: + """Tries to validate the api_key that is generated by the Pbench server . Args: - auth_token_s : authorization token string + api_key : authorization token string Returns: - None if the token is not valid, a `User` object when the token is valid. + None if the api_key is not valid, a `User` object when the api_key is valid. + """ - state = verify_internal_token(auth_token_s) - if state == TokenState.VERIFIED: - auth_token = AuthToken.query(auth_token_s) - user = auth_token.user if auth_token else None - else: - user = None - return user + key = APIKey.query(api_key) + return key.user if key else None def verify_auth_oidc(auth_token: str) -> Optional[User]: - """Verify a token provided to the Pbench server which was obtained from a - third party identity provider. + """Authorization token verification function. + + The verification will pass either if the token is from a third-party OIDC + identity provider or if the token is a Pbench Server API key. - Note: Upon token introspection if we get a valid token, we import the - available user information from the token into our internal User database. + The function will first attempt to validate the token as an OIDC access token. + If that fails, it will then attempt to validate it as a Pbench Server API key. + + If the token is a valid access token (and not if it is an API key), + we will import its contents into the internal user database. Args: auth_token : Token to authenticate @@ -225,7 +145,10 @@ def verify_auth_oidc(auth_token: str) -> Optional[User]: try: token_payload = oidc_client.token_introspect(token=auth_token) except OpenIDTokenInvalid: - pass + try: + user = verify_auth_api_key(auth_token) + except Exception: + pass except Exception: current_app.logger.exception( "Unexpected exception occurred while verifying the auth token {}", diff --git a/lib/pbench/server/database/__init__.py b/lib/pbench/server/database/__init__.py index ed60a41bbd..a5777db255 100644 --- a/lib/pbench/server/database/__init__.py +++ b/lib/pbench/server/database/__init__.py @@ -4,8 +4,8 @@ of the same is required here. """ from pbench.server.database.database import Database +from pbench.server.database.models.api_keys import APIKey # noqa F401 from pbench.server.database.models.audit import Audit # noqa F401 -from pbench.server.database.models.auth_tokens import AuthToken # noqa F401 from pbench.server.database.models.datasets import Dataset, Metadata # noqa F401 from pbench.server.database.models.server_settings import ServerSetting # noqa F401 from pbench.server.database.models.templates import Template # noqa F401 diff --git a/lib/pbench/server/database/alembic/versions/80c8c690f09b_api_key.py b/lib/pbench/server/database/alembic/versions/80c8c690f09b_api_key.py new file mode 100644 index 0000000000..9b3801755d --- /dev/null +++ b/lib/pbench/server/database/alembic/versions/80c8c690f09b_api_key.py @@ -0,0 +1,53 @@ +""" Update table for storing api_key and removing auth_token + + +Revision ID: 80c8c690f09b +Revises: f628657bed56 +Create Date: 2023-04-11 19:20:36.892126 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from pbench.server.database.models import TZDateTime + +# revision identifiers, used by Alembic. +revision = "80c8c690f09b" +down_revision = "f628657bed56" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("api_key", sa.String(length=500), nullable=False), + sa.Column("created", TZDateTime(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("api_key"), + ) + op.drop_index("ix_auth_tokens_expiration", table_name="auth_tokens") + op.drop_index("ix_auth_tokens_token", table_name="auth_tokens") + op.drop_table("auth_tokens") + + +def downgrade() -> None: + op.create_table( + "auth_tokens", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("token", sa.VARCHAR(length=500), autoincrement=False, nullable=False), + sa.Column( + "expiration", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.PrimaryKeyConstraint("id", name="auth_tokens_pkey"), + ) + op.create_index("ix_auth_tokens_token", "auth_tokens", ["token"], unique=False) + op.create_index( + "ix_auth_tokens_expiration", "auth_tokens", ["expiration"], unique=False + ) + op.drop_table("api_keys") diff --git a/lib/pbench/server/database/models/__init__.py b/lib/pbench/server/database/models/__init__.py index b65252ba88..4240b0698b 100644 --- a/lib/pbench/server/database/models/__init__.py +++ b/lib/pbench/server/database/models/__init__.py @@ -1,6 +1,8 @@ import datetime +from typing import Callable from sqlalchemy import DateTime +from sqlalchemy.exc import IntegrityError from sqlalchemy.types import TypeDecorator @@ -50,3 +52,26 @@ def process_result_value(self, value, dialect): if value is not None: value = value.replace(tzinfo=datetime.timezone.utc) return value + + +def decode_integrity_error( + exception: IntegrityError, on_null: Callable, on_duplicate: Callable +) -> Exception: + + """Decode a SQLAlchemy IntegrityError to look for a recognizable UNIQUE + or NOT NULL constraint violation. + + Return the original exception if it doesn't match. + + Args: + exception : An IntegrityError to decode + + Returns: + a more specific exception, or the original if decoding fails + """ + cause = exception.orig.args[-1] + if "UNIQUE constraint" in cause: + return on_duplicate(cause) + elif "NOT NULL constraint" in cause: + return on_null(cause) + return exception diff --git a/lib/pbench/server/database/models/api_keys.py b/lib/pbench/server/database/models/api_keys.py new file mode 100644 index 0000000000..579b764bb1 --- /dev/null +++ b/lib/pbench/server/database/models/api_keys.py @@ -0,0 +1,131 @@ +from typing import Optional + +from flask import current_app +import jwt +from sqlalchemy import Column, ForeignKey, String +from sqlalchemy.orm import relationship + +from pbench.server.database.database import Database +from pbench.server.database.models import decode_integrity_error, TZDateTime +from pbench.server.database.models.users import User + +# Module private constants +_TOKEN_ALG_INT = "HS256" + + +class APIKeyError(Exception): + """A base class for errors reported by the APIKey class.""" + + def __init__(self, message): + self.message = message + + def __str__(self): + return repr(self.message) + + +class DuplicateApiKey(APIKeyError): + """Attempt to commit a duplicate unique value.""" + + def __init__(self, cause: str): + self.cause = cause + + def __str__(self) -> str: + return f"Duplicate api_key: {self.cause}" + + +class NullKey(APIKeyError): + """Attempt to commit an APIkey row with an empty required column.""" + + def __init__(self, cause: str): + self.cause = cause + + def __str__(self) -> str: + return f"Missing required value: {self.cause}" + + +class APIKey(Database.Base): + """Model for storing the API key associated with a user.""" + + __tablename__ = "api_keys" + api_key = Column(String(500), primary_key=True) + created = Column(TZDateTime, nullable=False, default=TZDateTime.current_time) + # ID of the owning user + user_id = Column(String, ForeignKey("users.id"), nullable=False) + + # Indirect reference to the owning User record + user = relationship("User") + + def __str__(self): + return f"API key {self.api_key}" + + def add(self): + """Add an api_key object to the database.""" + try: + Database.db_session.add(self) + Database.db_session.commit() + except Exception as e: + Database.db_session.rollback() + self.logger.error("Can't add {} to DB: {}", str(self), str(e)) + decode_exc = decode_integrity_error( + e, on_duplicate=DuplicateApiKey, on_null=NullKey + ) + if decode_exc == e: + raise APIKeyError(str(e)) from e + else: + raise decode_exc from e + + @staticmethod + def query(key: str) -> Optional["APIKey"]: + """Find the given api_key in the database. + + Returns: + An APIKey object if found, otherwise None + """ + return Database.db_session.query(APIKey).filter_by(api_key=key).first() + + @staticmethod + def delete(api_key: str): + """Delete the given api_key. + + Args: + api_key : the api_key to delete + """ + dbs = Database.db_session + try: + dbs.query(APIKey).filter_by(api_key=api_key).delete() + dbs.commit() + except Exception as e: + dbs.rollback() + raise APIKeyError(f"Error deleting api_key from db : {e}") from e + + @staticmethod + def generate_api_key(user: User): + """Creates an `api_key` for the requested user + + Returns: + `api_key` or raises `APIKeyError` + """ + user_obj = user.get_json() + current_utc = TZDateTime.current_time() + payload = { + "iat": current_utc, + "user_id": user_obj["id"], + "username": user_obj["username"], + } + try: + generated_api_key = jwt.encode( + payload, current_app.secret_key, algorithm=_TOKEN_ALG_INT + ) + except ( + jwt.InvalidIssuer, + jwt.InvalidIssuedAtError, + jwt.InvalidAlgorithmError, + jwt.PyJWTError, + ): + current_app.logger.exception( + "Could not encode the JWT api_key for user: {} and the payload is : {}", + user, + payload, + ) + raise APIKeyError("Could not encode the JWT api_key for the user") + return generated_api_key diff --git a/lib/pbench/server/database/models/audit.py b/lib/pbench/server/database/models/audit.py index c40e24a7bf..9739d2eb45 100644 --- a/lib/pbench/server/database/models/audit.py +++ b/lib/pbench/server/database/models/audit.py @@ -80,8 +80,9 @@ class AuditType(enum.Enum): object_name.""" TEMPLATE = enum.auto() - """Operation on an API token. There's no meaningful id or name.""" - TOKEN = enum.auto() + """Operation on an API key. The 'attributes' field will be updated with the + key once it is generated""" + API_KEY = enum.auto() class AuditStatus(enum.Enum): diff --git a/lib/pbench/server/database/models/auth_tokens.py b/lib/pbench/server/database/models/auth_tokens.py deleted file mode 100644 index cc5c6e9aee..0000000000 --- a/lib/pbench/server/database/models/auth_tokens.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Optional - -from sqlalchemy import Column, DateTime, Integer, String - -from pbench.server.database.database import Database - - -class AuthToken(Database.Base): - """Model for storing the active auth tokens associated with a user. - - Each token is associated with a given User object, and stores its - expiration time. - """ - - __tablename__ = "auth_tokens" - id = Column(Integer, primary_key=True, autoincrement=True) - token = Column(String(500), unique=True, nullable=False, index=True) - expiration = Column(DateTime, nullable=False, index=True) - - @staticmethod - def query(auth_token: str) -> Optional["AuthToken"]: - """Find the given auth token in the database. - - Returns: - An AuthToken object if found, otherwise None - """ - # We currently only query token database for a specific token. - dbs = Database.db_session - return dbs.query(AuthToken).filter_by(token=auth_token).first() - - @staticmethod - def delete(auth_token: str): - """Delete the given auth token. - - If the auth token does not exist return silently. - - Args: - auth_token : the auth token to delete - """ - dbs = Database.db_session - try: - token = dbs.query(AuthToken).filter_by(token=auth_token) - if not token: - return - token.delete() - Database.db_session.commit() - except Exception: - dbs.rollback() - raise diff --git a/lib/pbench/test/unit/server/auth/test_auth.py b/lib/pbench/test/unit/server/auth/test_auth.py index 36063a360f..a0fbc09aaa 100644 --- a/lib/pbench/test/unit/server/auth/test_auth.py +++ b/lib/pbench/test/unit/server/auth/test_auth.py @@ -18,7 +18,6 @@ OpenIDTokenInvalid, ) import pbench.server.auth.auth as Auth -from pbench.server.database.models.users import User from pbench.test.unit.server import DRB_USER_ID from pbench.test.unit.server.conftest import jwt_secret @@ -430,12 +429,8 @@ class TestAuthModule: This class does not verify the setup_app() method itself as it is verified in other tests related to the overall Flask application setup. - It also does not attempt to verify get_current_user_id() and - encode_auth_token() since they are slated for removal and are covered well - by other parts of the unit testing for the server code. - - It does, however, verify the verify_auth_internal() method because we need - it to function properly until the use of an internal user is removed. + It verifies the verify_auth() method which covers both OIDC token verification + and also the Pbench server API key verification. """ def test_get_auth_token_succ(self, monkeypatch, make_logger): @@ -494,62 +489,33 @@ def record_abort(code: int, message: str = ""): ) assert record["code"] == expected_code - def test_verify_auth_exc(self, monkeypatch, make_logger): - """Verify exception handling originating from verify_auth_internal""" - - def vai_exc(token_auth: str) -> Optional[User]: - raise Exception("Some failure") - - monkeypatch.setattr(Auth, "verify_auth_internal", vai_exc) - app = Flask("test-verify-auth-exc") - app.logger = make_logger - with app.app_context(): - user = Auth.verify_auth("my-token") - assert user is None - - def test_verify_auth_internal(self, make_logger, pbench_drb_token): - """Verify success path of verify_auth_internal""" - app = Flask("test-verify-auth-internal") + def test_verify_auth(self, make_logger, pbench_drb_token): + """Verify success path of verify_auth""" + app = Flask("test-verify-auth") app.logger = make_logger with app.app_context(): current_app.secret_key = jwt_secret user = Auth.verify_auth(pbench_drb_token) - assert str(user.id) == DRB_USER_ID + assert user.id == DRB_USER_ID - def test_verify_auth_internal_invalid(self, make_logger, pbench_drb_token_invalid): - """Verify handling of an invalid (expired) token in verify_auth_internal""" - app = Flask("test-verify-auth-internal-invalid") + def test_verify_auth_invalid(self, make_logger, pbench_drb_token_invalid): + """Verify handling of an invalid (expired) token in verify_auth""" + app = Flask("test-verify-auth-invalid") app.logger = make_logger with app.app_context(): current_app.secret_key = jwt_secret user = Auth.verify_auth(pbench_drb_token_invalid) assert user is None - def test_verify_auth_internal_invsig(self, make_logger, pbench_drb_token): + def test_verify_auth_invsig(self, make_logger, pbench_drb_token): """Verify handling of a token with an invalid signature""" - app = Flask("test-verify-auth-internal-invsig") + app = Flask("test-verify-auth-invsig") app.logger = make_logger with app.app_context(): current_app.secret_key = jwt_secret user = Auth.verify_auth(pbench_drb_token + "1") assert user is None - def test_verify_auth_internal_tokdel_fail( - self, monkeypatch, make_logger, pbench_drb_token_invalid - ): - """Verify behavior when token deletion fails""" - - def delete(*args, **kwargs): - raise Exception("Delete failed") - - monkeypatch.setattr(Auth.AuthToken, "delete", delete) - app = Flask("test-verify-auth-internal-tokdel-fail") - app.logger = make_logger - with app.app_context(): - current_app.secret_key = jwt_secret - user = Auth.verify_auth(pbench_drb_token_invalid) - assert user is None - @pytest.mark.parametrize("roles", [["ROLE"], ["ROLE1", "ROLE2"], [], None]) def test_verify_auth_oidc(self, monkeypatch, rsa_keys, make_logger, roles): """Verify OIDC token offline verification success path""" @@ -638,3 +604,48 @@ def tio_exc(token: str) -> JSON: user = Auth.verify_auth(token) assert user is None + + def test_verify_auth_api_key( + self, monkeypatch, rsa_keys, make_logger, pbench_drb_api_key + ): + """Verify api_key verification via Auth.verify_auth()""" + + # Mock the Connection object and generate an OpenIDClient object, + # installing it as Auth module's OIDC client. + config = mock_connection(monkeypatch, "us", public_key=rsa_keys["public_key"]) + oidc_client = OpenIDClient.construct_oidc_client(config) + monkeypatch.setattr(Auth, "oidc_client", oidc_client) + + def tio_exc(token: str) -> JSON: + raise OpenIDTokenInvalid() + + app = Flask("test_verify_auth_api_key") + app.logger = make_logger + with app.app_context(): + monkeypatch.setattr(oidc_client, "token_introspect", tio_exc) + current_app.secret_key = jwt_secret + user = Auth.verify_auth(pbench_drb_api_key) + assert user.id == DRB_USER_ID + + def test_verify_auth_api_key_invalid( + self, monkeypatch, rsa_keys, make_logger, pbench_drb_api_key_invalid + ): + """Verify api_key verification via Auth.verify_auth() fails + gracefully with an invalid token + """ + # Mock the Connection object and generate an OpenIDClient object, + # installing it as Auth module's OIDC client. + config = mock_connection(monkeypatch, "us", public_key=rsa_keys["public_key"]) + oidc_client = OpenIDClient.construct_oidc_client(config) + monkeypatch.setattr(Auth, "oidc_client", oidc_client) + + def tio_exc(token: str) -> JSON: + raise OpenIDTokenInvalid() + + app = Flask("test_verify_auth_api_key_invalid") + app.logger = make_logger + with app.app_context(): + monkeypatch.setattr(oidc_client, "token_introspect", tio_exc) + current_app.secret_key = jwt_secret + user = Auth.verify_auth(pbench_drb_api_key_invalid) + assert user is None diff --git a/lib/pbench/test/unit/server/conftest.py b/lib/pbench/test/unit/server/conftest.py index d12217f615..cbab3ea14a 100644 --- a/lib/pbench/test/unit/server/conftest.py +++ b/lib/pbench/test/unit/server/conftest.py @@ -28,6 +28,7 @@ import pbench.server.auth.auth as Auth from pbench.server.database import init_db from pbench.server.database.database import Database +from pbench.server.database.models.api_keys import APIKey from pbench.server.database.models.datasets import Dataset, Metadata from pbench.server.database.models.templates import Template from pbench.server.database.models.users import User @@ -825,6 +826,21 @@ def get_token_func(pbench_admin_token, server_config, rsa_keys): ) +@pytest.fixture() +def pbench_drb_api_key(client, server_config, create_drb_user): + """Valid api_key for the 'drb' user""" + return generate_api_key( + username="drb", + user=create_drb_user, + ) + + +@pytest.fixture() +def pbench_drb_api_key_invalid(client, server_config, create_drb_user): + """Invalid api_key for the 'drb' user""" + return "pbench_drb_invalid_api_key" + + def generate_token( username: str, private_key: str, @@ -971,3 +987,42 @@ def tarball(tmp_path): md5file.unlink() if datafile.exists(): datafile.unlink() + + +def generate_api_key( + username: str, + user: Optional[User] = None, + valid: bool = True, +) -> str: + """Generates an api_key which will be mimic of how POST v1/generate_key call works. + + Note: The OIDC client id passed as an argument has to match with the + oidc client id from the default config file. Otherwise the token + validation will fail in the server code. + + Args: + username: username to include in the token payload + user: user attributes will be extracted from the user object to include + in the token payload. + valid: If True, the generated key will be valid for 10 mins. + If False, generated key will be invalid and expired + + Returns: + JWT token string + """ + # Current time to encode in the token payload + current_utc = datetime.datetime.now(datetime.timezone.utc) + + if not user: + user = User.query(username=username) + assert user + + payload = { + "iat": current_utc, + "user_id": user.id, + "username": user.username, + } + key = jwt.encode(payload, jwt_secret, algorithm="HS256") + api_key = APIKey(api_key=key, user=user) + api_key.add() + return key diff --git a/lib/pbench/test/unit/server/database/test_api_keys_db.py b/lib/pbench/test/unit/server/database/test_api_keys_db.py new file mode 100644 index 0000000000..a71832680a --- /dev/null +++ b/lib/pbench/test/unit/server/database/test_api_keys_db.py @@ -0,0 +1,59 @@ +import pytest + +from pbench.server.database.database import Database +from pbench.server.database.models import TZDateTime +from pbench.server.database.models.api_keys import APIKey +from pbench.test.unit.server import DRB_USER_ID +from pbench.test.unit.server.database import FakeSession + + +class TestAPIKeyDB: + + session = None + + # Current time to encode in the payload + current_utc = TZDateTime.current_time() + + @pytest.fixture() + def fake_db(self, monkeypatch, server_config): + """ + Fixture to mock a DB session for testing. + + We patch the SQLAlchemy db_session to our fake session. We also store a + server configuration object directly on the Database.Base (normally + done during DB initialization) because that can't be monkeypatched. + """ + __class__.session = FakeSession(APIKey) + monkeypatch.setattr(Database, "db_session", __class__.session) + Database.Base.config = server_config + yield monkeypatch + + def test_construct(self, db_session, create_drb_user, create_user): + """Test api_key constructor""" + + api_key = APIKey( + api_key="generated_api_key", + user=create_user, + ) + api_key.add() + + assert api_key.api_key == "generated_api_key" + assert api_key.user.id is create_user.id + + def test_query(self, db_session, pbench_drb_api_key): + """Test that we can able to query api_key in the table""" + + key = APIKey.query(pbench_drb_api_key) + assert key.api_key == pbench_drb_api_key + assert key.user.id == DRB_USER_ID + assert key.user.username == "drb" + + def test_delete(self, db_session, create_user, pbench_drb_api_key): + """Test that we can delete an api_key""" + + # we can find it + key = APIKey.query(pbench_drb_api_key) + assert key.api_key == pbench_drb_api_key + + APIKey.delete(pbench_drb_api_key) + assert APIKey.query(pbench_drb_api_key) is None diff --git a/lib/pbench/test/unit/server/test_api_key.py b/lib/pbench/test/unit/server/test_api_key.py new file mode 100644 index 0000000000..4fc838f7e2 --- /dev/null +++ b/lib/pbench/test/unit/server/test_api_key.py @@ -0,0 +1,93 @@ +from http import HTTPStatus + +import pytest +import requests + +from pbench.server import OperationCode +from pbench.server.database.models.audit import Audit, AuditStatus, AuditType + + +class TestAPIKey: + @pytest.fixture() + def query_get_as(self, client, server_config): + """ + Helper fixture to perform the API query and validate an expected + return status. + + Args: + client: Flask test API client fixture + server_config: Pbench config fixture + """ + + def query_api(user_token, expected_status: HTTPStatus) -> requests.Response: + headers = {"authorization": f"bearer {user_token}"} + response = client.post( + f"{server_config.rest_uri}/key", + headers=headers, + ) + assert response.status_code == expected_status + return response + + return query_api + + def test_unauthorized_access(self, query_get_as, pbench_drb_token_invalid): + response = query_get_as(pbench_drb_token_invalid, HTTPStatus.UNAUTHORIZED) + assert response.json == { + "message": "User provided access_token is invalid or expired" + } + audit = Audit.query() + assert len(audit) == 2 + assert audit[0].id == 1 + assert audit[0].root_id is None + assert audit[0].operation == OperationCode.CREATE + assert audit[0].status == AuditStatus.BEGIN + assert audit[0].name == "apikey" + assert audit[0].object_type == AuditType.API_KEY + assert audit[0].object_id is None + assert audit[0].object_name is None + assert audit[0].user_id is None + assert audit[0].user_name is None + assert audit[0].reason is None + assert audit[0].attributes is None + assert audit[1].id == 2 + assert audit[1].root_id == 1 + assert audit[1].operation == OperationCode.CREATE + assert audit[1].status == AuditStatus.FAILURE + assert audit[1].name == "apikey" + assert audit[1].object_type == AuditType.API_KEY + assert audit[1].object_id is None + assert audit[1].object_name is None + assert audit[1].user_id is None + assert audit[1].user_name is None + assert audit[1].reason is None + assert audit[1].attributes is None + + def test_successful_api_key_generation(self, query_get_as, pbench_drb_token): + response = query_get_as(pbench_drb_token, HTTPStatus.CREATED) + assert response.json["api_key"] + audit = Audit.query() + assert len(audit) == 2 + assert audit[0].id == 1 + assert audit[0].root_id is None + assert audit[0].operation == OperationCode.CREATE + assert audit[0].status == AuditStatus.BEGIN + assert audit[0].name == "apikey" + assert audit[0].object_type == AuditType.API_KEY + assert audit[0].object_id is None + assert audit[0].object_name is None + assert audit[0].user_id == "3" + assert audit[0].user_name == "drb" + assert audit[0].reason is None + assert audit[0].attributes is None + assert audit[1].id == 2 + assert audit[1].root_id == 1 + assert audit[1].operation == OperationCode.CREATE + assert audit[1].status == AuditStatus.SUCCESS + assert audit[1].name == "apikey" + assert audit[1].object_type == AuditType.API_KEY + assert audit[1].object_id is None + assert audit[1].object_name is None + assert audit[1].user_id == "3" + assert audit[1].user_name == "drb" + assert audit[1].reason is None + assert audit[1].attributes["key"] == response.json["api_key"] diff --git a/lib/pbench/test/unit/server/test_endpoint_configure.py b/lib/pbench/test/unit/server/test_endpoint_configure.py index 046d720fcf..518fbe6e0a 100644 --- a/lib/pbench/test/unit/server/test_endpoint_configure.py +++ b/lib/pbench/test/unit/server/test_endpoint_configure.py @@ -89,6 +89,7 @@ def check_config(self, client, server_config, host, my_headers={}): }, }, "endpoints": {"template": f"{uri}/endpoints", "params": {}}, + "key": {"template": f"{uri}/key", "params": {}}, "server_audit": {"template": f"{uri}/server/audit", "params": {}}, "server_settings": { "template": f"{uri}/server/settings/{{key}}",