diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8793b138..75559ee6 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -16,7 +16,7 @@ Install dependencies .. code-block:: bash - $ pip install -r requirements/dev.in + $ pip install -r requirements/dev.txt $ pip install -r requirements/docs.in Install the package in editable mode @@ -44,7 +44,7 @@ or $ sphinx-build -b html docs docs/_build -Run the tests together or individually +Run the tests together or individually, requires the docker containers to be up and running (see below) .. code-block:: bash diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a49a86b8..8be17b23 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,5 +1,6 @@ ## Contributors +- [MauriceBrg](https://github.com/MauriceBrg) - [giuppep](https://github.com/giuppep) - [eiriklid](https://github.com/eiriklid) - [necat1](https://github.com/necat1) diff --git a/docs/config_reference.rst b/docs/config_reference.rst index e3ace977..9fef8fd5 100644 --- a/docs/config_reference.rst +++ b/docs/config_reference.rst @@ -18,6 +18,7 @@ These are specific to Flask-Session. - **cachelib**: CacheLibSessionInterface - **mongodb**: MongoDBSessionInterface - **sqlalchemy**: SqlAlchemySessionInterface + - **dynamodb**: DynamoDBSessionInterface .. py:data:: SESSION_PERMANENT @@ -215,6 +216,12 @@ Dynamodb Default: ``'Sessions'`` +.. py:data:: SESSION_DYNAMODB_TABLE_EXISTS + + By default it will create a new table with the TTL setting activated unless you set this parameter to ``True``, then it assumes that the table already exists. + + Default: ``False`` + .. deprecated:: 0.7.0 ``SESSION_FILE_DIR``, ``SESSION_FILE_THRESHOLD``, ``SESSION_FILE_MODE``. Use ``SESSION_CACHELIB`` instead. diff --git a/docs/config_serialization.rst b/docs/config_serialization.rst index fc3f5325..f393b554 100644 --- a/docs/config_serialization.rst +++ b/docs/config_serialization.rst @@ -14,7 +14,7 @@ The msgspec library has speed and memory advantages over other libraries. Howeve If you encounter a TypeError such as: "Encoding objects of type is unsupported", you may be attempting to serialize an unsupported type. In this case, you can either convert the object to a supported type or use a different serializer. Casting to a supported type: -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python diff --git a/src/flask_session/__init__.py b/src/flask_session/__init__.py index f527beb5..5d4caee9 100644 --- a/src/flask_session/__init__.py +++ b/src/flask_session/__init__.py @@ -106,6 +106,9 @@ def _get_interface(self, app): SESSION_DYNAMODB_TABLE = config.get( "SESSION_DYNAMODB_TABLE", Defaults.SESSION_DYNAMODB_TABLE ) + SESSION_DYNAMODB_TABLE_EXISTS = config.get( + "SESSION_DYNAMODB_TABLE_EXISTS", Defaults.SESSION_DYNAMODB_TABLE_EXISTS + ) # PostgreSQL settings SESSION_POSTGRESQL = config.get( @@ -191,6 +194,7 @@ def _get_interface(self, app): **common_params, client=SESSION_DYNAMODB, table_name=SESSION_DYNAMODB_TABLE, + table_exists=SESSION_DYNAMODB_TABLE_EXISTS, ) elif SESSION_TYPE == "postgresql": diff --git a/src/flask_session/defaults.py b/src/flask_session/defaults.py index 0467db46..f1bc1501 100644 --- a/src/flask_session/defaults.py +++ b/src/flask_session/defaults.py @@ -43,8 +43,9 @@ class Defaults: # DynamoDB settings SESSION_DYNAMODB = None SESSION_DYNAMODB_TABLE = "Sessions" + SESSION_DYNAMODB_TABLE_EXISTS = False # PostgreSQL settings SESSION_POSTGRESQL = None SESSION_POSTGRESQL_TABLE = "flask_sessions" - SESSION_POSTGRESQL_SCHEMA = "public" + SESSION_POSTGRESQL_SCHEMA = "public" \ No newline at end of file diff --git a/src/flask_session/dynamodb/dynamodb.py b/src/flask_session/dynamodb/dynamodb.py index a9c448ec..a7d4e3a0 100644 --- a/src/flask_session/dynamodb/dynamodb.py +++ b/src/flask_session/dynamodb/dynamodb.py @@ -1,3 +1,5 @@ +"""Provides a Session Interface to DynamoDB""" + import warnings from datetime import datetime from datetime import timedelta as TimeDelta @@ -5,9 +7,9 @@ from typing import Optional import boto3 -from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource from flask import Flask from itsdangerous import want_bytes +from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource from ..base import ServerSideSession, ServerSideSessionInterface from ..defaults import Defaults @@ -20,12 +22,41 @@ class DynamoDBSession(ServerSideSession): class DynamoDBSessionInterface(ServerSideSessionInterface): """A Session interface that uses dynamodb as backend. (`boto3` required) - :param client: A ``DynamoDBServiceResource`` instance. + By default (``table_exists=False``) it will create a DynamoDB table with this configuration: + + - Table Name: Value of ``table_name``, by default ``Sessions`` + - Key Schema: Simple Primary Key ``id`` of type string + - Billing Mode: Pay per Request + - Time to Live enabled, attribute name: ``expiration`` + - The following permissions are required: + - ``dynamodb:CreateTable`` + - ``dynamodb:DescribeTable`` + - ``dynamodb:UpdateTimeToLive`` + - ``dynamodb:GetItem`` + - ``dynamodb:UpdateItem`` + - ``dynamodb:DeleteItem`` + + If you set ``table_exists`` to True, you're responsible for creating a table with this config: + + - Table Name: Value of ``table_name``, by default ``Sessions`` + - Key Schema: Simple Primary Key ``id`` of type string + - Time to Live enabled, attribute name: ``expiration`` + - The following permissions are required under these circumstances: + - ``dynamodb:GetItem`` + - ``dynamodb:UpdateItem`` + - ``dynamodb:DeleteItem`` + + :param client: A ``DynamoDBServiceResource`` instance, i.e. the result + of ``boto3.resource("dynamodb", ...)``. :param key_prefix: A prefix that is added to all DynamoDB store keys. :param use_signer: Whether to sign the session id cookie or not. :param permanent: Whether to use permanent session or not. :param sid_length: The length of the generated session id in bytes. :param table_name: DynamoDB table name to store the session. + :param table_exists: The table already exists, don't try to create it (default=False). + + .. versionadded:: 0.9 + The `table_exists` parameter was added. .. versionadded:: 0.6 The `sid_length` parameter was added. @@ -46,8 +77,11 @@ def __init__( sid_length: int = Defaults.SESSION_ID_LENGTH, serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT, table_name: str = Defaults.SESSION_DYNAMODB_TABLE, + table_exists: Optional[bool] = Defaults.SESSION_DYNAMODB_TABLE_EXISTS, ): + # NOTE: The name client is a bit misleading as we're using the resource API of boto3 as opposed to the service API + # which would be instantiated as boto3.client. if client is None: warnings.warn( "No valid DynamoDBServiceResource instance provided, attempting to create a new instance on localhost:8000.", @@ -62,44 +96,58 @@ def __init__( aws_secret_access_key="dummy", ) + self.client = client + self.table_name = table_name + + if not table_exists: + self._create_table() + + self.store = client.Table(table_name) + super().__init__( + app, + key_prefix, + use_signer, + permanent, + sid_length, + serialization_format, + ) + + def _create_table(self): try: - client.create_table( + self.client.create_table( AttributeDefinitions=[ {"AttributeName": "id", "AttributeType": "S"}, ], - TableName=table_name, + TableName=self.table_name, KeySchema=[ {"AttributeName": "id", "KeyType": "HASH"}, ], BillingMode="PAY_PER_REQUEST", ) - client.meta.client.get_waiter("table_exists").wait(TableName=table_name) - client.meta.client.update_time_to_live( + self.client.meta.client.get_waiter("table_exists").wait( + TableName=self.table_name + ) + self.client.meta.client.update_time_to_live( TableName=self.table_name, TimeToLiveSpecification={ "Enabled": True, "AttributeName": "expiration", }, ) - except (AttributeError, client.meta.client.exceptions.ResourceInUseException): + except ( + AttributeError, + self.client.meta.client.exceptions.ResourceInUseException, + ): # TTL already exists, or table already exists pass - self.client = client - self.store = client.Table(table_name) - super().__init__( - app, - key_prefix, - use_signer, - permanent, - sid_length, - serialization_format, - ) - def _retrieve_session_data(self, store_id: str) -> Optional[dict]: # Get the saved session (document) from the database document = self.store.get_item(Key={"id": store_id}).get("Item") - if document: + session_is_not_expired = Decimal(datetime.utcnow().timestamp()) <= document.get( + "expiration" + ) + if document and session_is_not_expired: serialized_session_data = want_bytes(document.get("val").value) return self.serializer.loads(serialized_session_data) return None diff --git a/tests/test_dynamodb.py b/tests/test_dynamodb.py index a0e1f1fb..0168a53c 100644 --- a/tests/test_dynamodb.py +++ b/tests/test_dynamodb.py @@ -2,6 +2,7 @@ import boto3 import flask +import pytest from flask_session.defaults import Defaults from flask_session.dynamodb import DynamoDBSession @@ -52,3 +53,43 @@ def test_dynamodb_default(self, app_utils): with app.test_request_context(): assert isinstance(flask.session, DynamoDBSession) app_utils.test_session(app) + + def test_dynamodb_with_existing_table(self, app_utils): + """ + Setting the SESSION_DYNAMODB_TABLE_EXISTS to True for an + existing table shouldn't change anything. + """ + + with self.setup_dynamodb(): + app = app_utils.create_app( + { + "SESSION_TYPE": "dynamodb", + "SESSION_DYNAMODB": self.client, + "SESSION_DYNAMODB_TABLE_EXISTS": True, + } + ) + + with app.test_request_context(): + assert isinstance(flask.session, DynamoDBSession) + app_utils.test_session(app) + + def test_dynamodb_with_existing_table_fails_if_table_doesnt_exist(self, app_utils): + """Accessing a non-existent table should result in problems.""" + + app = app_utils.create_app( + { + "SESSION_TYPE": "dynamodb", + "SESSION_DYNAMODB": boto3.resource( + "dynamodb", + endpoint_url="http://localhost:8000", + region_name="us-west-2", + aws_access_key_id="dummy", + aws_secret_access_key="dummy", + ), + "SESSION_DYNAMODB_TABLE": "non-existent-123", + "SESSION_DYNAMODB_TABLE_EXISTS": True, + } + ) + with app.test_request_context(), pytest.raises(AssertionError): + assert isinstance(flask.session, DynamoDBSession) + app_utils.test_session(app)