diff --git a/neon_data_models/enum.py b/neon_data_models/enum.py index 345be3f..f506307 100644 --- a/neon_data_models/enum.py +++ b/neon_data_models/enum.py @@ -34,6 +34,10 @@ class AccessRoles(IntEnum): non-user roles. In this way, an activity can require, for example, `permission > AccessRoles.GUEST` to grant access to all registered users, admins, and owners. + + Special Roles: + NODE: Reserved for use by a Node device service account to access + various services """ NONE = 0 # 1-9 reserved for unauthenticated connections diff --git a/neon_data_models/models/api/mq.py b/neon_data_models/models/api/mq.py index 37602b3..dc1dd53 100644 --- a/neon_data_models/models/api/mq.py +++ b/neon_data_models/models/api/mq.py @@ -24,17 +24,86 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from typing import Literal, Optional +from typing import Literal, Optional, Annotated, Union + +from pydantic import Field, TypeAdapter, model_validator + +from neon_data_models.models.api.jwt import HanaToken from neon_data_models.models.base.contexts import MQContext from neon_data_models.models.user.database import User -class UserDbRequest(MQContext): - operation: Literal["create", "read", "update", "delete"] - username: str - password: Optional[str] = None - access_token: Optional[str] = None - user: Optional[User] = None +class CreateUserRequest(MQContext): + operation: Literal["create"] = "create" + user: User = Field(description="User object to create") + + +class ReadUserRequest(MQContext): + operation: Literal["read"] = "read" + user_spec: str = Field(description="Username or User ID to read") + auth_user_spec: str = Field( + default="", description="Username or ID to authorize database read. " + "If unset, this will use `user_spec`") + access_token: Optional[HanaToken] = Field( + None, description="Token associated with `auth_username`") + password: Optional[str] = Field(None, + description="Password associated with " + "`auth_username`") + + @model_validator(mode="after") + def validate_params(self) -> 'ReadUserRequest': + if not self.auth_user_spec: + self.auth_user_spec = self.user_spec + if self.access_token and self.access_token.purpose != "access": + raise ValueError(f"Expected an access token but got: " + f"{self.access_token.purpose}") + return self + + +class UpdateUserRequest(MQContext): + operation: Literal["update"] = "update" + user: User = Field(description="Updated User object to write to database") + auth_username: str = Field( + default="", description="Username to authorize database change. If " + "unset, this will use `user.username`") + auth_password: str = Field( + default="", description="Password (clear or hashed) associated with " + "`auth_username`. If unset, this will use " + "`user.password_hash`. If changing the " + "password, this must contain the existing " + "password, with the new password specified in " + "`user`") + + @model_validator(mode="after") + def get_auth_username(self) -> 'UpdateUserRequest': + if not self.auth_username: + self.auth_username = self.user.username + if not self.auth_password: + self.auth_password = self.user.password_hash + if not all((self.auth_username, self.auth_password)): + raise ValueError("Missing auth_username or auth_password") + return self + + +class DeleteUserRequest(MQContext): + operation: Literal["delete"] = "delete" + user: User = Field(description="Exact User object to remove from the " + "database") + + +class UserDbRequest: + """ + Generic class to dynamically build a UserDB CRUD request object based on the + requested `operation` + """ + ta = TypeAdapter(Annotated[Union[CreateUserRequest, ReadUserRequest, + UpdateUserRequest, DeleteUserRequest], + Field(discriminator='operation')]) + + def __new__(cls, *args, **kwargs): + return cls.ta.validate_python(kwargs) -__all__ = [UserDbRequest.__name__] +__all__ = [CreateUserRequest.__name__, ReadUserRequest.__name__, + UpdateUserRequest.__name__, DeleteUserRequest.__name__, + UserDbRequest.__name__] diff --git a/neon_data_models/models/base/contexts.py b/neon_data_models/models/base/contexts.py index 46db3d7..f715169 100644 --- a/neon_data_models/models/base/contexts.py +++ b/neon_data_models/models/base/contexts.py @@ -23,11 +23,10 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from datetime import datetime, timedelta from typing import Literal, List, Optional -from pydantic import Field - from neon_data_models.models.base import BaseModel diff --git a/neon_data_models/models/base/messagebus.py b/neon_data_models/models/base/messagebus.py index 08721bf..8773b8b 100644 --- a/neon_data_models/models/base/messagebus.py +++ b/neon_data_models/models/base/messagebus.py @@ -30,8 +30,8 @@ from neon_data_models.models.base import BaseModel from neon_data_models.models.base.contexts import (SessionContext, KlatContext, TimingContext, MQContext) -from neon_data_models.models.client import NodeData -from neon_data_models.models.user import NeonUserConfig +from neon_data_models.models.client.node import NodeData +from neon_data_models.models.user.database import NeonUserConfig class MessageContext(BaseModel): diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index af6f5fc..4e7e885 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -29,7 +29,7 @@ from typing_extensions import deprecated from uuid import uuid4 -from neon_data_models.models.api.jwt import HanaToken +# from neon_data_models.models.api import HanaToken from neon_data_models.models.base import BaseModel from pydantic import Field from datetime import date @@ -115,14 +115,28 @@ class BrainForgeConfig(BaseModel): class PermissionsConfig(BaseModel): """ - Defines roles for supported projects/service families. + Defines roles for supported projects/services. """ - klat: AccessRoles = AccessRoles.NONE - core: AccessRoles = AccessRoles.NONE - diana: AccessRoles = AccessRoles.NONE - node: AccessRoles = AccessRoles.NONE - hub: AccessRoles = AccessRoles.NONE - llm: AccessRoles = AccessRoles.NONE + klat: AccessRoles = Field( + AccessRoles.NONE, description="Defines access to Klat chat services.") + core: AccessRoles = Field( + AccessRoles.NONE, description="Defines access to Neon core services.") + diana: AccessRoles = Field( + AccessRoles.NONE, + description="Defines access to DIANA backend services. " + "(i.e. API proxy, email proxy).") + users: AccessRoles = Field( + AccessRoles.NONE, description="Defines access to the users service.") + node: AccessRoles = Field( + AccessRoles.NONE, + description="Defines access to the node websocket in HANA.") + hub: AccessRoles = Field( + AccessRoles.NONE, description="Defines access to a hub device.") + llm: AccessRoles = Field( + AccessRoles.NONE, + description="Defines access to the BrainForge LLM backend. Note that " + "per-model permissions may also apply and further restrict " + "a user's access to some models for inference.") class Config: use_enum_values = True @@ -167,6 +181,12 @@ class TokenConfig(BaseModel): class User(BaseModel): + def __init__(self, **kwargs): + # Ensure `HanaToken` is populated from the import space + from neon_data_models.models.api.jwt import HanaToken + self.model_rebuild() + BaseModel.__init__(self, **kwargs) + username: str password_hash: Optional[str] = None user_id: str = Field(default_factory=lambda: str(uuid4())) @@ -175,7 +195,7 @@ class User(BaseModel): klat: KlatConfig = KlatConfig() llm: BrainForgeConfig = BrainForgeConfig() permissions: PermissionsConfig = PermissionsConfig() - tokens: Optional[List[HanaToken]] = [] + tokens: Optional[List['HanaToken']] = [] def __eq__(self, other): return self.model_dump() == other.model_dump() diff --git a/tests/models/api/test_mq.py b/tests/models/api/test_mq.py index 610dc27..4768ad9 100644 --- a/tests/models/api/test_mq.py +++ b/tests/models/api/test_mq.py @@ -31,14 +31,98 @@ class TestMQ(TestCase): - def test_user_db_request(self): - valid_model = UserDbRequest(operation="create", username="test_user", - message_id="test") - self.assertIsInstance(valid_model, UserDbRequest) + def test_create_user_db_request(self): + from neon_data_models.models.api.mq import CreateUserRequest + + # Test create user valid + valid_kwargs = {"message_id": "test_id", "operation": "create", + "user": {"username": "test_user"}} + create_request = CreateUserRequest(**valid_kwargs) + self.assertIsInstance(create_request, CreateUserRequest) + generic_request = UserDbRequest(**valid_kwargs) + self.assertIsInstance(generic_request, CreateUserRequest) + self.assertEqual(generic_request.user.username, + create_request.user.username) + + # Test invalid with self.assertRaises(ValidationError): - UserDbRequest(operation="get", username="test", message_id="test") + UserDbRequest(operation="create", username="test", + message_id="test0") + + def test_read_user_db_request(self): + from neon_data_models.models.api.mq import ReadUserRequest + + # Test read user valid + valid_kwargs = {"message_id": "test_id", "operation": "read", + "user_spec": "test_user"} + read_request = ReadUserRequest(**valid_kwargs) + self.assertIsInstance(read_request, ReadUserRequest) + generic_request = UserDbRequest(**valid_kwargs) + self.assertIsInstance(generic_request, ReadUserRequest) + self.assertEqual(generic_request.user_spec, + read_request.user_spec) + + # Test invalid with self.assertRaises(ValidationError): - UserDbRequest(operation="delete", username="test_user", - user="test_user", message_id="test") + UserDbRequest(operation="read", user={"username": "test"}, + message_id="test0") + + def test_update_user_db_request(self): + from neon_data_models.models.api.mq import UpdateUserRequest + + # Test update user valid + valid_kwargs = {"message_id": "test_id", "operation": "update", + "auth_password": "test_password", + "user": {"username": "test_user", + "skills": {"skill_id": {"test": True}}}} + update_request = UpdateUserRequest(**valid_kwargs) + self.assertIsInstance(update_request, UpdateUserRequest) + self.assertEqual(update_request.auth_username, + update_request.user.username) + generic_request = UserDbRequest(**valid_kwargs) + self.assertIsInstance(generic_request, UpdateUserRequest) + self.assertEqual(generic_request.user.username, + update_request.user.username) + + # Test update read username/password from User object + update = UpdateUserRequest(message_id="test_id", operation="update", + user={"username": "user", + "password_hash": "password"}) + self.assertEqual(update.auth_username, "user") + self.assertEqual(update.auth_password, "password") + + # Test update with separate authentication user + update = UpdateUserRequest(message_id="test_id", operation="update", + user={"username": "user", + "password_hash": "password"}, + auth_username="admin", auth_password="admin_pass") + self.assertEqual(update.user.username, "user") + self.assertEqual(update.user.password_hash, "password") + + self.assertEqual(update.auth_username, "admin") + self.assertEqual(update.auth_password, "admin_pass") + + # Test invalid + with self.assertRaises(ValidationError): + UserDbRequest(operation="update", user={"username": "test_user", + "skills": {"skill_id": { + "test": True}}}, + message_id="test0") + + def test_delete_user_db_request(self): + from neon_data_models.models.api.mq import DeleteUserRequest + + # Test delete user valid + valid_kwargs = {"message_id": "test_id", "operation": "delete", + "user": {"username": "test_user"}} + delete_request = DeleteUserRequest(**valid_kwargs) + self.assertIsInstance(delete_request, DeleteUserRequest) + generic_request = UserDbRequest(**valid_kwargs) + self.assertIsInstance(generic_request, DeleteUserRequest) + self.assertEqual(generic_request.user.username, + delete_request.user.username) + + # Test invalid with self.assertRaises(ValidationError): - UserDbRequest(operation="create", username="test_user") + UserDbRequest(operation="delete", username="test_user", + message_id="test0") diff --git a/tests/models/test_base.py b/tests/models/test_base.py index 47ae5ba..0665af8 100644 --- a/tests/models/test_base.py +++ b/tests/models/test_base.py @@ -27,14 +27,10 @@ import importlib import os from datetime import datetime, timedelta - from unittest import TestCase from time import time from pydantic import ValidationError -from neon_data_models.models.client import NodeData -from neon_data_models.models.user import NeonUserConfig - class TestBaseModel(TestCase): def test_base_model(self): @@ -60,6 +56,19 @@ def test_base_model(self): self.assertEqual(model.model_config["extra"], "ignore") self.assertEqual(allowed.model_config["extra"], "allow") + # Ensure modules are unloaded for future inheritance tests + import sys + for module in list(sys.modules.keys()): + if module.startswith("neon_data_models"): + del sys.modules[module] + + def test_base_model_inheritance(self): + from neon_data_models.models.base import BaseModel + from neon_data_models.models.user.database import PermissionsConfig + config = PermissionsConfig() + self.assertTrue(isinstance(config, BaseModel)) + self.assertIsInstance(config.model_config["extra"], str) + class TestContexts(TestCase): def test_session_context(self): @@ -135,6 +144,8 @@ def test_mq_context(self): class TestMessagebus(TestCase): def test_base_model(self): from neon_data_models.models.base.messagebus import BaseMessage + from neon_data_models.models.client import NodeData + from neon_data_models.models.user import NeonUserConfig with self.assertRaises(ValidationError): BaseMessage() @@ -162,6 +173,8 @@ def test_base_model(self): def test_message_context(self): from neon_data_models.models.base.messagebus import MessageContext + from neon_data_models.models.client import NodeData + from neon_data_models.models.user import NeonUserConfig # Default Behavior default_context = MessageContext() diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..fcf3c0d --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,56 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2024 Neongecko.com Inc. +# BSD-3 +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from unittest import TestCase + + +class TestImports(TestCase): + def test_import_api(self): + from neon_data_models.models.api import CreateUserRequest + from neon_data_models.models.api.mq import CreateUserRequest as _CUR + self.assertEqual(CreateUserRequest, _CUR) + + def test_import_client(self): + from neon_data_models.models.client import NodeData + from neon_data_models.models.client.node import NodeData as _ND + self.assertEqual(NodeData, _ND) + + def test_import_user(self): + from neon_data_models.models.user import User + from neon_data_models.models.user.database import User as _User + self.assertEqual(User, _User) + user = User(username="test_user", password_hash="test_pass") + self.assertIsInstance(user, User) + + def test_import_subclasses(self): + # Addressing circular import noted in users service unit tests + from neon_data_models.models.user.database import User + from neon_data_models.models.client.node import NodeData + from neon_data_models.models.api.mq import CreateUserRequest + from neon_data_models.models.base import BaseModel + self.assertTrue(issubclass(CreateUserRequest, BaseModel)) + self.assertTrue(issubclass(NodeData, BaseModel)) + self.assertTrue(issubclass(User, BaseModel))