2
0
Fork 0

Compare commits

...

5 commits

Author SHA1 Message Date
Lance Edgar 1995095627 build: just run pytest, avoid tox when making release 2024-07-14 11:08:26 -05:00
Lance Edgar 5d15ef9720 bump: version 0.6.1 → 0.7.0 2024-07-14 11:07:03 -05:00
Lance Edgar b4d6cfb0ed fix: always use 'wutta' prefix for provider entry points
otherwise not all providers will be discoverable, for custom appname

also add `appname` prop for GenericHandler, AppProvider
2024-07-14 10:45:13 -05:00
Lance Edgar f6d0912c03 docs: update project urls to wuttaproject.org 2024-07-14 09:28:58 -05:00
Lance Edgar 639b0de8b1 feat: add basic "auth" data models: user/role/perm
not really tested yet though, other than unit tests
2024-07-13 23:29:09 -05:00
16 changed files with 430 additions and 20 deletions

View file

@ -5,6 +5,16 @@ 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/) 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). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.7.0 (2024-07-14)
### Feat
- add basic "auth" data models: user/role/perm
### Fix
- always use 'wutta' prefix for provider entry points
## v0.6.1 (2024-07-12) ## v0.6.1 (2024-07-12)
### Fix ### Fix

View file

@ -0,0 +1,6 @@
``wuttjamaican.auth``
=====================
.. automodule:: wuttjamaican.auth
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.db.model.auth``
==============================
.. automodule:: wuttjamaican.db.model.auth
:members:

View file

@ -8,10 +8,12 @@
:maxdepth: 1 :maxdepth: 1
app app
auth
conf conf
db db
db.conf db.conf
db.model db.model
db.model.auth
db.model.base db.model.base
db.sess db.sess
exc exc

View file

@ -55,6 +55,12 @@ Glossary
See also the code-friendly :term:`app name`. See also the code-friendly :term:`app name`.
auth handler
A :term:`handler` responsible for user authentication and
authorization (login, permissions) and related things.
See also :class:`~wuttjamaican.auth.AuthHandler`.
command command
A top-level command line interface for the app. Note that A top-level command line interface for the app. Note that
top-level commands don't usually "do" anything per se, and are top-level commands don't usually "do" anything per se, and are
@ -71,6 +77,9 @@ Glossary
happens is, a config object is created and then extended by each happens is, a config object is created and then extended by each
of the registered config extensions. of the registered config extensions.
The intention is that all config extensions will have been
applied before the :term:`app handler` is created.
config file config file
A file which contains :term:`config settings<config setting>`. A file which contains :term:`config settings<config setting>`.
See also :doc:`narr/config/files`. See also :doc:`narr/config/files`.

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttJamaican" name = "WuttJamaican"
version = "0.6.1" version = "0.7.0"
description = "Base package for Wutta Framework" description = "Base package for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -38,9 +38,9 @@ tests = ["pytest-cov", "tox"]
[project.urls] [project.urls]
Homepage = "https://rattailproject.org/" Homepage = "https://wuttaproject.org/"
Repository = "https://kallithea.rattailproject.org/rattail-project/wuttjamaican" Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican"
Changelog = "https://kallithea.rattailproject.org/rattail-project/wuttjamaican/files/master/CHANGELOG.md" Changelog = "https://forgejo.wuttaproject.org/wutta/wuttjamaican/src/branch/master/CHANGELOG.md"
[tool.commitizen] [tool.commitizen]

View file

@ -105,9 +105,6 @@ class AppHandler:
self.providers = self.get_all_providers() self.providers = self.get_all_providers()
return self.providers return self.providers
# if 'providers' not in self.__dict__:
# self.__dict__['providers'] = self.get_all_providers()
for provider in self.providers.values(): for provider in self.providers.values():
if hasattr(provider, name): if hasattr(provider, name):
return getattr(provider, name) return getattr(provider, name)
@ -121,10 +118,22 @@ class AppHandler:
Note that you do not need to call this directly; instead just Note that you do not need to call this directly; instead just
use :attr:`providers`. use :attr:`providers`.
The discovery logic is based on :term:`entry points<entry
point>` using the ``wutta.app.providers`` group. For instance
here is a sample entry point used by WuttaWeb (in its
``pyproject.toml``):
.. code-block:: toml
[project.entry-points."wutta.app.providers"]
wuttaweb = "wuttaweb.app:WebAppProvider"
:returns: Dictionary keyed by entry point name; values are :returns: Dictionary keyed by entry point name; values are
:class:`AppProvider` *instances*. :class:`AppProvider` instances.
""" """
providers = load_entry_points(f'{self.appname}.providers') # nb. must use 'wutta' and not self.appname prefix here, or
# else we can't find all providers with custom appname
providers = load_entry_points('wutta.app.providers')
for key in list(providers): for key in list(providers):
providers[key] = providers[key](self.config) providers[key] = providers[key](self.config)
return providers return providers
@ -278,8 +287,7 @@ class AppProvider:
These can add arbitrary extra functionality to the main :term:`app These can add arbitrary extra functionality to the main :term:`app
handler`. See also :doc:`/narr/providers/app`. handler`. See also :doc:`/narr/providers/app`.
:param config: Config object for the app. This should be an :param config: The app :term:`config object`.
instance of :class:`~wuttjamaican.conf.WuttaConfig`.
Instances have the following attributes: Instances have the following attributes:
@ -301,7 +309,16 @@ class AppProvider:
config = config.config config = config.config
self.config = config self.config = config
self.app = config.get_app() self.app = self.config.get_app()
@property
def appname(self):
"""
The :term:`app name` for the current app.
See also :attr:`AppHandler.appname`.
"""
return self.app.appname
class GenericHandler: class GenericHandler:
@ -318,3 +335,12 @@ class GenericHandler:
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
self.config = config self.config = config
self.app = self.config.get_app() self.app = self.config.get_app()
@property
def appname(self):
"""
The :term:`app name` for the current app.
See also :attr:`AppHandler.appname`.
"""
return self.app.appname

44
src/wuttjamaican/auth.py Normal file
View file

@ -0,0 +1,44 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Auth Handler
This defines the default :term:`auth handler`.
"""
from wuttjamaican.app import GenericHandler
class AuthHandler(GenericHandler):
"""
Base class and default implementation for the :term:`auth
handler`.
This is responsible for "authentication and authorization" - for
instance:
* create new users, roles
* grant/revoke role permissions
* determine which permissions a user has
* identify user from login credentials
"""

View file

@ -21,14 +21,21 @@
# #
################################################################################ ################################################################################
""" """
WuttJamaican - database model Data Models
For convenience, from this ``wuttjamaican.db.model`` namespace you can This is the default :term:`app model` module.
access the following:
The ``wuttjamaican.db.model`` namespace contains the following:
* :func:`~wuttjamaican.db.model.base.uuid_column()`
* :func:`~wuttjamaican.db.model.base.uuid_fk_column()`
* :class:`~wuttjamaican.db.model.base.Base` * :class:`~wuttjamaican.db.model.base.Base`
* :class:`~wuttjamaican.db.model.base.Setting` * :class:`~wuttjamaican.db.model.base.Setting`
* :func:`~wuttjamaican.db.model.base.uuid_column()` * :class:`~wuttjamaican.db.model.auth.Role`
* :class:`~wuttjamaican.db.model.auth.Permission`
* :class:`~wuttjamaican.db.model.auth.User`
* :class:`~wuttjamaican.db.model.auth.UserRole`
""" """
from .base import Base, uuid_column, Setting from .base import Base, Setting, uuid_column, uuid_fk_column
from .auth import Role, Permission, User, UserRole

View file

@ -0,0 +1,229 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Auth Models
The :term:`auth handler` is primarily responsible for managing the
data for these models.
Basic design/structure is as follows:
* :class:`User` may be assigned to multiple roles
* :class:`Role` may contain multiple users (cf. :class:`UserRole`)
* :class:`Role` may be granted multiple permissions
* :class:`Permission` is a permission granted to a role
* roles are not nested/grouped; each is independent
* a few roles are built-in, e.g. Administrators
So a user's permissions are "inherited" from the role(s) to which they
belong.
"""
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from .base import Base, uuid_column, uuid_fk_column
class Role(Base):
"""
Represents an authentication role within the system; used for
permission management.
.. attribute:: permissions
List of keys (string names) for permissions granted to this
role.
See also :attr:`permission_refs`.
.. attribute:: users
List of :class:`User` instances belonging to this role.
See also :attr:`user_refs`.
"""
__tablename__ = 'role'
__table_args__ = (
sa.UniqueConstraint('name',
# TODO
# name='role_uq_name',
),
)
uuid = uuid_column()
name = sa.Column(sa.String(length=100), nullable=False, doc="""
Name for the role. Each role must have a name, which must be
unique.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Arbitrary notes for the role.
""")
permission_refs = orm.relationship(
'Permission',
back_populates='role',
doc="""
List of :class:`Permission` references for the role.
See also :attr:`permissions`.
""")
permissions = association_proxy(
'permission_refs', 'permission',
creator=lambda p: Permission(permission=p),
# TODO
# getset_factory=getset_factory,
)
user_refs = orm.relationship(
'UserRole',
# TODO
# cascade='all, delete-orphan',
# cascade_backrefs=False,
back_populates='role',
doc="""
List of :class:`UserRole` instances belonging to the role.
See also :attr:`users`.
""")
users = association_proxy(
'user_refs', 'user',
creator=lambda u: UserRole(user=u),
# TODO
# getset_factory=getset_factory,
)
def __str__(self):
return self.name or ""
class Permission(Base):
"""
Represents a permission granted to a role.
"""
__tablename__ = 'permission'
__table_args__ = (
sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'],
# TODO
# name='permission_fk_role',
),
)
role_uuid = uuid_fk_column(primary_key=True, nullable=False)
role = orm.relationship(
Role,
back_populates='permission_refs',
doc="""
Reference to the :class:`Role` for which the permission is
granted.
""")
permission = sa.Column(sa.String(length=254), primary_key=True, doc="""
Key (name) of the permission which is granted.
""")
def __str__(self):
return self.permission or ""
class User(Base):
"""
Represents a user of the system.
This may or may not correspond to a real person, i.e. some users
may exist solely for automated tasks.
"""
__tablename__ = 'user'
__table_args__ = (
sa.UniqueConstraint('username',
# TODO
# name='user_uq_username',
),
)
uuid = uuid_column()
username = sa.Column(sa.String(length=25), nullable=False, doc="""
Account username. This is required and must be unique.
""")
password = sa.Column(sa.String(length=60), nullable=True, doc="""
Hashed password for login. (The raw password is not stored.)
""")
active = sa.Column(sa.Boolean(), nullable=False, default=True, doc="""
Flag indicating whether the user account is "active" - it is
``True`` by default.
The default auth logic will prevent login for "inactive" user accounts.
""")
role_refs = orm.relationship(
'UserRole',
back_populates='user',
doc="""
List of :class:`UserRole` records.
""")
def __str__(self):
return self.username or ""
class UserRole(Base):
"""
Represents the association between a user and a role.
"""
__tablename__ = 'user_x_role'
__table_args__ = (
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'],
# TODO
# name='user_x_role_fk_user',
),
sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'],
# TODO
# name='user_x_role_fk_role',
),
)
uuid = uuid_column()
user_uuid = uuid_fk_column(nullable=False)
user = orm.relationship(
User,
back_populates='role_refs',
doc="""
Reference to the :class:`User` involved.
""")
role_uuid = uuid_fk_column(nullable=False)
role = orm.relationship(
Role,
back_populates='user_refs',
doc="""
Reference to the :class:`Role` involved.
""")

View file

@ -21,7 +21,7 @@
# #
################################################################################ ################################################################################
""" """
WuttJamaican - base models Base Models
.. class:: Base .. class:: Base
@ -47,6 +47,13 @@ def uuid_column(*args, **kwargs):
return sa.Column(sa.String(length=32), *args, **kwargs) return sa.Column(sa.String(length=32), *args, **kwargs)
def uuid_fk_column(*args, **kwargs):
"""
Returns a UUID column for use as a foreign key to another table.
"""
return sa.Column(sa.String(length=32), *args, **kwargs)
class Setting(Base): class Setting(Base):
""" """
Represents a :term:`config setting`. Represents a :term:`config setting`.

View file

@ -30,7 +30,7 @@ def release(c, skip_tests=False):
Release a new version of WuttJamaican Release a new version of WuttJamaican
""" """
if not skip_tests: if not skip_tests:
c.run('tox') c.run('pytest')
# rebuild local tar.gz file for distribution # rebuild local tar.gz file for distribution
if os.path.exists('WuttJamaican.egg-info'): if os.path.exists('WuttJamaican.egg-info'):

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
try:
import sqlalchemy as sa
from wuttjamaican.db.model import auth as model
except ImportError:
pass
else:
class TestRole(TestCase):
def test_basic(self):
role = model.Role()
self.assertEqual(str(role), "")
role.name = "Managers"
self.assertEqual(str(role), "Managers")
class TestPermission(TestCase):
def test_basic(self):
perm = model.Permission()
self.assertEqual(str(perm), "")
perm.permission = 'users.create'
self.assertEqual(str(perm), "users.create")
class TestUser(TestCase):
def test_basic(self):
user = model.User()
self.assertEqual(str(user), "")
user.username = 'barney'
self.assertEqual(str(user), "barney")

View file

@ -14,7 +14,16 @@ else:
def test_basic(self): def test_basic(self):
column = model.uuid_column() column = model.uuid_column()
self.assertIsInstance(column, sa.Column) self.assertIsInstance(column, sa.Column)
self.assertIsInstance(column.type, sa.String)
self.assertEqual(column.type.length, 32)
class TestUUIDFKColumn(TestCase):
def test_basic(self):
column = model.uuid_column()
self.assertIsInstance(column, sa.Column)
self.assertIsInstance(column.type, sa.String)
self.assertEqual(column.type.length, 32)
class TestSetting(TestCase): class TestSetting(TestCase):

View file

@ -134,6 +134,7 @@ class TestAppProvider(TestCase):
provider = app.AppProvider(self.config) provider = app.AppProvider(self.config)
self.assertIs(provider.config, self.config) self.assertIs(provider.config, self.config)
self.assertIs(provider.app, self.app) self.assertIs(provider.app, self.app)
self.assertEqual(provider.appname, 'wuttatest')
# but can pass app handler instead # but can pass app handler instead
with warnings.catch_warnings(): with warnings.catch_warnings():
@ -155,7 +156,7 @@ class TestAppProvider(TestCase):
# sanity check, we get *instances* back from this # sanity check, we get *instances* back from this
providers = self.app.get_all_providers() providers = self.app.get_all_providers()
load_entry_points.assert_called_once_with('wuttatest.providers') load_entry_points.assert_called_once_with('wutta.app.providers')
self.assertEqual(len(providers), 1) self.assertEqual(len(providers), 1)
self.assertIn('fake', providers) self.assertIn('fake', providers)
self.assertIsInstance(providers['fake'], FakeProvider) self.assertIsInstance(providers['fake'], FakeProvider)
@ -212,3 +213,4 @@ class TestGenericHandler(TestCase):
handler = app.GenericHandler(self.config) handler = app.GenericHandler(self.config)
self.assertIs(handler.config, self.config) self.assertIs(handler.config, self.config)
self.assertIs(handler.app, self.app) self.assertIs(handler.app, self.app)
self.assertEqual(handler.appname, 'wuttatest')

17
tests/test_auth.py Normal file
View file

@ -0,0 +1,17 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from wuttjamaican import auth as mod
from wuttjamaican.conf import WuttaConfig
class TestAuthHandler(TestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
def test_basic(self):
handler = mod.AuthHandler(self.config)
self.assertIs(handler.app, self.app)