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`. 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 . +# +################################################################################ +""" +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 . +# +################################################################################ +""" +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)