From 639b0de8b112c9efac4a9bed988420b97b8014f4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 13 Jul 2024 23:25:20 -0500
Subject: [PATCH 1/5] feat: add basic "auth" data models: user/role/perm

not really tested yet though, other than unit tests
---
 docs/api/wuttjamaican/auth.rst          |   6 +
 docs/api/wuttjamaican/db.model.auth.rst |   6 +
 docs/api/wuttjamaican/index.rst         |   2 +
 docs/glossary.rst                       |   9 +
 src/wuttjamaican/auth.py                |  44 +++++
 src/wuttjamaican/db/model/__init__.py   |  17 +-
 src/wuttjamaican/db/model/auth.py       | 229 ++++++++++++++++++++++++
 src/wuttjamaican/db/model/base.py       |   9 +-
 tests/db/model/test_auth.py             |  36 ++++
 tests/db/model/test_base.py             |   9 +
 tests/test_auth.py                      |  17 ++
 11 files changed, 378 insertions(+), 6 deletions(-)
 create mode 100644 docs/api/wuttjamaican/auth.rst
 create mode 100644 docs/api/wuttjamaican/db.model.auth.rst
 create mode 100644 src/wuttjamaican/auth.py
 create mode 100644 src/wuttjamaican/db/model/auth.py
 create mode 100644 tests/db/model/test_auth.py
 create mode 100644 tests/test_auth.py

diff --git a/docs/api/wuttjamaican/auth.rst b/docs/api/wuttjamaican/auth.rst
new file mode 100644
index 0000000..b2e7382
--- /dev/null
+++ b/docs/api/wuttjamaican/auth.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.auth``
+=====================
+
+.. automodule:: wuttjamaican.auth
+   :members:
diff --git a/docs/api/wuttjamaican/db.model.auth.rst b/docs/api/wuttjamaican/db.model.auth.rst
new file mode 100644
index 0000000..fdb1da9
--- /dev/null
+++ b/docs/api/wuttjamaican/db.model.auth.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.db.model.auth``
+==============================
+
+.. automodule:: wuttjamaican.db.model.auth
+   :members:
diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst
index 452a183..ddc050c 100644
--- a/docs/api/wuttjamaican/index.rst
+++ b/docs/api/wuttjamaican/index.rst
@@ -8,10 +8,12 @@
    :maxdepth: 1
 
    app
+   auth
    conf
    db
    db.conf
    db.model
+   db.model.auth
    db.model.base
    db.sess
    exc
diff --git a/docs/glossary.rst b/docs/glossary.rst
index 6845af9..2c9863a 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -55,6 +55,12 @@ Glossary
 
      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
      A top-level command line interface for the app.  Note that
      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
       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
      A file which contains :term:`config settings<config setting>`.
      See also :doc:`narr/config/files`.
diff --git a/src/wuttjamaican/auth.py b/src/wuttjamaican/auth.py
new file mode 100644
index 0000000..90a314f
--- /dev/null
+++ b/src/wuttjamaican/auth.py
@@ -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
+    """
diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py
index aa6877d..66b36e5 100644
--- a/src/wuttjamaican/db/model/__init__.py
+++ b/src/wuttjamaican/db/model/__init__.py
@@ -21,14 +21,21 @@
 #
 ################################################################################
 """
-WuttJamaican -  database model
+Data Models
 
-For convenience, from this ``wuttjamaican.db.model`` namespace you can
-access the following:
+This is the default :term:`app model` module.
 
+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.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
diff --git a/src/wuttjamaican/db/model/auth.py b/src/wuttjamaican/db/model/auth.py
new file mode 100644
index 0000000..297e891
--- /dev/null
+++ b/src/wuttjamaican/db/model/auth.py
@@ -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.
+        """)
diff --git a/src/wuttjamaican/db/model/base.py b/src/wuttjamaican/db/model/base.py
index 45a7eee..5de65ca 100644
--- a/src/wuttjamaican/db/model/base.py
+++ b/src/wuttjamaican/db/model/base.py
@@ -21,7 +21,7 @@
 #
 ################################################################################
 """
-WuttJamaican -  base models
+Base Models
 
 .. class:: Base
 
@@ -47,6 +47,13 @@ def uuid_column(*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):
     """
     Represents a :term:`config setting`.
diff --git a/tests/db/model/test_auth.py b/tests/db/model/test_auth.py
new file mode 100644
index 0000000..29ba802
--- /dev/null
+++ b/tests/db/model/test_auth.py
@@ -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")
diff --git a/tests/db/model/test_base.py b/tests/db/model/test_base.py
index 646e330..aa27702 100644
--- a/tests/db/model/test_base.py
+++ b/tests/db/model/test_base.py
@@ -14,7 +14,16 @@ else:
         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 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):
 
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..6b51b66
--- /dev/null
+++ b/tests/test_auth.py
@@ -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)

From f6d0912c033b56baf9b412ae68686e4d530cfa9e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 09:28:58 -0500
Subject: [PATCH 2/5] docs: update project urls to wuttaproject.org

---
 pyproject.toml | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 79b3ff1..2225497 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,9 +38,9 @@ tests = ["pytest-cov", "tox"]
 
 
 [project.urls]
-Homepage = "https://rattailproject.org/"
-Repository = "https://kallithea.rattailproject.org/rattail-project/wuttjamaican"
-Changelog = "https://kallithea.rattailproject.org/rattail-project/wuttjamaican/files/master/CHANGELOG.md"
+Homepage = "https://wuttaproject.org/"
+Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican"
+Changelog = "https://forgejo.wuttaproject.org/wutta/wuttjamaican/src/branch/master/CHANGELOG.md"
 
 
 [tool.commitizen]

From b4d6cfb0ed5f9c383dead85b37c1f027378b744d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 10:45:13 -0500
Subject: [PATCH 3/5] 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
---
 src/wuttjamaican/app.py | 42 +++++++++++++++++++++++++++++++++--------
 tests/test_app.py       |  4 +++-
 2 files changed, 37 insertions(+), 9 deletions(-)

diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py
index 86a7b4c..f551be9 100644
--- a/src/wuttjamaican/app.py
+++ b/src/wuttjamaican/app.py
@@ -105,9 +105,6 @@ class AppHandler:
             self.providers = self.get_all_providers()
             return self.providers
 
-        # if 'providers' not in self.__dict__:
-        #     self.__dict__['providers'] = self.get_all_providers()
-
         for provider in self.providers.values():
             if hasattr(provider, name):
                 return getattr(provider, name)
@@ -121,10 +118,22 @@ class AppHandler:
         Note that you do not need to call this directly; instead just
         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
-           :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):
             providers[key] = providers[key](self.config)
         return providers
@@ -278,8 +287,7 @@ class AppProvider:
     These can add arbitrary extra functionality to the main :term:`app
     handler`.  See also :doc:`/narr/providers/app`.
 
-    :param config: Config object for the app.  This should be an
-       instance of :class:`~wuttjamaican.conf.WuttaConfig`.
+    :param config: The app :term:`config object`.
 
     Instances have the following attributes:
 
@@ -301,7 +309,16 @@ class AppProvider:
             config = 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:
@@ -318,3 +335,12 @@ class GenericHandler:
     def __init__(self, config, **kwargs):
         self.config = config
         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
diff --git a/tests/test_app.py b/tests/test_app.py
index 30adf72..ea1d4e4 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -134,6 +134,7 @@ class TestAppProvider(TestCase):
         provider = app.AppProvider(self.config)
         self.assertIs(provider.config, self.config)
         self.assertIs(provider.app, self.app)
+        self.assertEqual(provider.appname, 'wuttatest')
 
         # but can pass app handler instead
         with warnings.catch_warnings():
@@ -155,7 +156,7 @@ class TestAppProvider(TestCase):
 
             # sanity check, we get *instances* back from this
             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.assertIn('fake', providers)
             self.assertIsInstance(providers['fake'], FakeProvider)
@@ -212,3 +213,4 @@ class TestGenericHandler(TestCase):
         handler = app.GenericHandler(self.config)
         self.assertIs(handler.config, self.config)
         self.assertIs(handler.app, self.app)
+        self.assertEqual(handler.appname, 'wuttatest')

From 5d15ef97207e7de85d22f4d4bdc6b6dfb646edd5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 11:07:03 -0500
Subject: [PATCH 4/5] =?UTF-8?q?bump:=20version=200.6.1=20=E2=86=92=200.7.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 10 ++++++++++
 pyproject.toml |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8b4a03..feb3e0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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/)
 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)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 2225497..8809b13 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "WuttJamaican"
-version = "0.6.1"
+version = "0.7.0"
 description = "Base package for Wutta Framework"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 19950956272ec1cc6f5d2fa874692a4166adbad0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 14 Jul 2024 11:08:26 -0500
Subject: [PATCH 5/5] build: just run pytest, avoid tox when making release

---
 tasks.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tasks.py b/tasks.py
index a3ce31e..9f2850b 100644
--- a/tasks.py
+++ b/tasks.py
@@ -30,7 +30,7 @@ def release(c, skip_tests=False):
     Release a new version of WuttJamaican
     """
     if not skip_tests:
-        c.run('tox')
+        c.run('pytest')
 
     # rebuild local tar.gz file for distribution
     if os.path.exists('WuttJamaican.egg-info'):