diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b2d40..7ae7825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to WuttJamaican will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.22.0 (2025-08-09) - -### Feat - -- add WuttaConfigProfile base class -- add user API tokens; handler methods to manage/authenticate -- allow arbitrary kwargs for `config.get()` and `app.get_setting()` - -## v0.21.1 (2025-06-29) - ## v0.21.0 (2025-06-29) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 082971f..954a7a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.22.0" +version = "0.21.1" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] diff --git a/src/wuttjamaican/auth.py b/src/wuttjamaican/auth.py index 2f8abea..c69146d 100644 --- a/src/wuttjamaican/auth.py +++ b/src/wuttjamaican/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2025 Lance Edgar +# Copyright © 2023-2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -26,11 +26,8 @@ Auth Handler This defines the default :term:`auth handler`. """ -import secrets import uuid as _uuid -from sqlalchemy import orm - from wuttjamaican.app import GenericHandler @@ -76,8 +73,6 @@ class AuthHandler(GenericHandler): default logic assumes a "username" but in practice it may be an email address etc. - whatever the user entered. - See also :meth:`authenticate_user_token()`. - :param session: Open :term:`db session`. :param username: Usually a string, but also may be a @@ -96,33 +91,6 @@ class AuthHandler(GenericHandler): if self.check_user_password(user, password): return user - def authenticate_user_token(self, session, token): - """ - Authenticate the given user API token string, and if valid, - return the corresponding user. - - See also :meth:`authenticate_user()`. - - :param session: Open :term:`db session`. - - :param token: Raw token string for the user. - - :returns: :class:`~wuttjamaican.db.model.auth.User` instance, - or ``None``. - """ - model = self.app.model - - try: - token = session.query(model.UserAPIToken)\ - .filter(model.UserAPIToken.token_string == token)\ - .one() - except orm.exc.NoResultFound: - pass - else: - user = token.user - if user.active: - return user - def check_user_password(self, user, password, **kwargs): """ Check a user's password. @@ -577,63 +545,6 @@ class AuthHandler(GenericHandler): if permission in role.permissions: role.permissions.remove(permission) - ############################## - # API token methods - ############################## - - def add_api_token(self, user, description): - """ - Add and return a new API token for the user. - - This calls :meth:`generate_api_token_string()` to obtain the - actual token string. - - See also :meth:`delete_api_token()`. - - :param user: :class:`~wuttjamaican.db.model.auth.User` - instance for which to add the token. - - :param description: String description for the token. - - :rtype: :class:`~wuttjamaican.db.model.auth.UserAPIToken` - """ - model = self.app.model - session = self.app.get_session(user) - - # generate raw token - token_string = self.generate_api_token_string() - - # persist token in DB - token = model.UserAPIToken( - description=description, - token_string=token_string) - user.api_tokens.append(token) - session.add(token) - - return token - - def generate_api_token_string(self): - """ - Generate a new *raw* API token string. - - This is called by :meth:`add_api_token()`. - - :returns: Raw API token string. - """ - return secrets.token_urlsafe() - - def delete_api_token(self, token): - """ - Delete the given API token. - - See also :meth:`add_api_token()`. - - :param token: - :class:`~wuttjamaican.db.model.auth.UserAPIToken` instance. - """ - session = self.app.get_session(token) - session.delete(token) - ############################## # internal methods ############################## diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index daad029..b207681 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -1013,89 +1013,3 @@ def make_config( extension.startup(config) return config - - -class WuttaConfigProfile: - """ - Base class to represent a configured "profile" in the context of - some service etc. - - :param config: App :term:`config object`. - - :param key: Config key for the profile. - - Generally each subclass will represent a certain type of config - profile, and each instance will represent a single profile - (identified by the ``key``). - """ - - def __init__(self, config, key): - self.config = config - self.app = self.config.get_app() - self.key = key - self.load() - - @property - def section(self): - """ - The primary config section under which profiles may be - defined. - - There is no default; each subclass must declare it. - - This corresponds to the typical INI file section, for instance - a section of ``wutta.telemetry`` assumes file contents like: - - .. code-block:: ini - - [wutta.telemetry] - default.submit_url = /nodes/telemetry - special.submit_url = /nodes/telemetry-special - """ - raise NotImplementedError - - def load(self): - """ - Read all relevant settings from config, and assign attributes - on the profile instance accordingly. - - There is no default logic but subclass will generally override. - - While a caller can use :meth:`get_str()` to obtain arbitrary - config values dynamically, it is often useful for the profile - to pre-load some config values. This allows "smarter" - interpretation of config values in some cases, and at least - ensures common/shared logic. - - There is no constraint or other guidance in terms of which - profile attributes might be set by this method. Subclass - should document if necessary. - """ - - def get_str(self, option, **kwargs): - """ - Get a string value for the profile, from config. - - :param option: Name of config option for which to return value. - - This just calls :meth:`~WuttaConfig.get()` on the config - object, but for a particular setting name which it composes - dynamically. - - Assuming a config file like: - - .. code-block:: ini - - [wutta.telemetry] - default.submit_url = /nodes/telemetry - - Then a ``default`` profile under the ``wutta.telemetry`` - section would effectively have a ``submit_url`` option:: - - class TelemetryProfile(WuttaConfigProfile): - section = "wutta.telemetry" - - profile = TelemetryProfile("default") - url = profile.get_str("submit_url") - """ - return self.config.get(f'{self.section}.{self.key}.{option}', **kwargs) diff --git a/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py b/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py deleted file mode 100644 index 61ed5fa..0000000 --- a/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py +++ /dev/null @@ -1,39 +0,0 @@ -"""add user_api_token - -Revision ID: efdcb2c75034 -Revises: 6bf900765500 -Create Date: 2025-08-08 08:58:19.376105 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import wuttjamaican.db.util - - -# revision identifiers, used by Alembic. -revision: str = 'efdcb2c75034' -down_revision: Union[str, None] = '6bf900765500' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - - # user_api_token - op.create_table('user_api_token', - sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), - sa.Column('user_uuid', wuttjamaican.db.util.UUID(), nullable=False), - sa.Column('description', sa.String(length=255), nullable=False), - sa.Column('token_string', sa.String(length=255), nullable=False), - sa.Column('created', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name=op.f('fk_user_api_token_user_uuid_user')), - sa.PrimaryKeyConstraint('uuid', name=op.f('pk_user_api_token')) - ) - - -def downgrade() -> None: - - # user_api_token - op.drop_table('user_api_token') diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py index 0fcb14a..6a9883b 100644 --- a/src/wuttjamaican/db/model/__init__.py +++ b/src/wuttjamaican/db/model/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2025 Lance Edgar +# Copyright © 2023-2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -40,7 +40,6 @@ And the :term:`data models `: * :class:`~wuttjamaican.db.model.auth.Permission` * :class:`~wuttjamaican.db.model.auth.User` * :class:`~wuttjamaican.db.model.auth.UserRole` -* :class:`~wuttjamaican.db.model.auth.UserAPIToken` * :class:`~wuttjamaican.db.model.upgrades.Upgrade` And the :term:`batch` model base/mixin classes: @@ -52,6 +51,6 @@ And the :term:`batch` model base/mixin classes: from wuttjamaican.db.util import uuid_column, uuid_fk_column, UUID from .base import Base, Setting, Person -from .auth import Role, Permission, User, UserRole, UserAPIToken +from .auth import Role, Permission, User, UserRole from .upgrades import Upgrade from .batch import BatchMixin, BatchRowMixin diff --git a/src/wuttjamaican/db/model/auth.py b/src/wuttjamaican/db/model/auth.py index eb42a47..44e71b4 100644 --- a/src/wuttjamaican/db/model/auth.py +++ b/src/wuttjamaican/db/model/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2025 Lance Edgar +# Copyright © 2023-2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -39,8 +39,6 @@ So a user's permissions are "inherited" from the role(s) to which they belong. """ -import datetime - import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import association_proxy @@ -213,16 +211,6 @@ class User(Base): # getset_factory=getset_factory, ) - api_tokens = orm.relationship( - 'UserAPIToken', - back_populates='user', - order_by='UserAPIToken.created', - cascade='all, delete-orphan', - cascade_backrefs=False, - doc=""" - List of :class:`UserAPIToken` instances belonging to the user. - """) - def __str__(self): if self.person: name = str(self.person) @@ -258,36 +246,3 @@ class UserRole(Base): doc=""" Reference to the :class:`Role` involved. """) - - -class UserAPIToken(Base): - """ - User authentication token for use with HTTP API - """ - __tablename__ = 'user_api_token' - - uuid = uuid_column() - - user_uuid = uuid_fk_column('user.uuid', nullable=False) - user = orm.relationship( - User, - back_populates='api_tokens', - cascade_backrefs=False, - doc=""" - Reference to the :class:`User` whose token this is. - """) - - description = sa.Column(sa.String(length=255), nullable=False, doc=""" - Description of the token. - """) - - token_string = sa.Column(sa.String(length=255), nullable=False, doc=""" - Raw token string, to be used by API clients. - """) - - created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" - Date/time when the token was created. - """) - - def __str__(self): - return self.description or "" diff --git a/tests/db/model/test_auth.py b/tests/db/model/test_auth.py index bdee085..6bcacc5 100644 --- a/tests/db/model/test_auth.py +++ b/tests/db/model/test_auth.py @@ -43,12 +43,3 @@ else: person = Person(full_name="Barney Rubble") user.person = person self.assertEqual(str(user), "Barney Rubble") - - - class TestUserAPIToken(TestCase): - - def test_str(self): - token = model.UserAPIToken() - self.assertEqual(str(token), "") - token.description = "test token" - self.assertEqual(str(token), "test token") diff --git a/tests/test_auth.py b/tests/test_auth.py index c1b048b..d4d1a9c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -63,32 +63,6 @@ else: user = self.handler.authenticate_user(self.session, 'barney', 'goodpass') self.assertIsNone(user) - def test_authenticate_user_token(self): - model = self.app.model - barney = model.User(username='barney') - self.session.add(barney) - token = self.handler.add_api_token(barney, "test token") - self.session.commit() - - user = self.handler.authenticate_user_token(self.session, None) - self.assertIsNone(user) - - user = self.handler.authenticate_user_token(self.session, token.token_string) - self.assertIs(user, barney) - - barney.active = False - self.session.flush() - user = self.handler.authenticate_user_token(self.session, token.token_string) - self.assertIsNone(user) - - barney.active = True - self.session.flush() - user = self.handler.authenticate_user_token(self.session, token.token_string) - self.assertIs(user, barney) - - user = self.handler.authenticate_user_token(self.session, 'bad-token') - self.assertIsNone(user) - def test_check_user_password(self): model = self.app.model barney = model.User(username='barney') @@ -442,32 +416,3 @@ else: self.handler.revoke_permission(myrole, 'foo') self.session.commit() self.assertEqual(self.session.query(model.Permission).count(), 0) - - def test_generate_api_token_string(self): - token = self.handler.generate_api_token_string() - # TODO: not sure how to propertly test this yet... - self.assertEqual(len(token), 43) - - def test_add_api_token(self): - model = self.app.model - barney = model.User(username='barney') - self.session.add(barney) - - token = self.handler.add_api_token(barney, "test token") - self.assertIs(token.user, barney) - self.assertEqual(token.description, "test token") - # TODO: not sure how to propertly test this yet... - self.assertEqual(len(token.token_string), 43) - - def test_delete_api_token(self): - model = self.app.model - barney = model.User(username='barney') - self.session.add(barney) - token = self.handler.add_api_token(barney, "test token") - self.session.commit() - - self.session.refresh(barney) - self.assertEqual(len(barney.api_tokens), 1) - self.handler.delete_api_token(token) - self.session.refresh(barney) - self.assertEqual(len(barney.api_tokens), 0) diff --git a/tests/test_conf.py b/tests/test_conf.py index 75271ca..5b46539 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -12,7 +12,7 @@ from wuttjamaican import conf as mod from wuttjamaican import conf from wuttjamaican.exc import ConfigurationError from wuttjamaican.app import AppHandler -from wuttjamaican.testing import FileTestCase, ConfigTestCase +from wuttjamaican.testing import FileTestCase class TestWuttaConfig(FileTestCase): @@ -867,21 +867,3 @@ class TestMakeConfig(FileTestCase): foo_cls.assert_called_once_with() foo_obj.configure.assert_called_once_with(testconfig) foo_obj.startup.assert_called_once_with(testconfig) - - -class TestWuttaConfigProfile(ConfigTestCase): - - def make_profile(self, key): - return mod.WuttaConfigProfile(self.config, key) - - def test_section(self): - profile = self.make_profile('default') - self.assertRaises(NotImplementedError, getattr, profile, 'section') - - def test_get_str(self): - self.config.setdefault('wutta.telemetry.default.submit_url', '/nodes/telemetry') - with patch.object(mod.WuttaConfigProfile, 'section', new='wutta.telemetry'): - profile = self.make_profile('default') - self.assertEqual(profile.section, 'wutta.telemetry') - url = profile.get_str('submit_url') - self.assertEqual(url, '/nodes/telemetry')