Compare commits
No commits in common. "5b7117078cb255fbafc69d3659e93747d7b01cee" and "988749d80eedcd8010f0a2e45ba445cc0bb57dc1" have entirely different histories.
5b7117078c
...
988749d80e
10 changed files with 6 additions and 358 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
@ -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"}]
|
||||
|
|
|
@ -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
|
||||
##############################
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
|
@ -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 <data model>`:
|
|||
* :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
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue