Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update UserDbRequest models #3

Merged
merged 19 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ed5f899
Add BaseModel for JWT and `HanaToken` (to use for auth and refresh)
NeonDaniel Nov 4, 2024
d3ad743
Update TokenConfig to better accept alternate named parameters with c…
NeonDaniel Nov 5, 2024
7c8fa47
Update TokenConfig to handle JWT standard field names
NeonDaniel Nov 6, 2024
f7eb6c9
Update comment in TokenConfig
NeonDaniel Nov 7, 2024
5026c47
Mark `TokenConfig` as deprecated
NeonDaniel Nov 12, 2024
4eac171
Update deprecation warning to use builtin decorator
NeonDaniel Nov 12, 2024
e5c0d34
Refactors `UserDbRequest` into seprate schemas for CRUD operations
NeonDaniel Nov 8, 2024
f64aea3
Add optional authentication to `UpdateUserRequest` model to allow for…
NeonDaniel Nov 8, 2024
1dca865
Update tests to handle refactored model params
NeonDaniel Nov 8, 2024
798e937
Refactor `ReadUserRequest` to require some form of authentication for…
NeonDaniel Nov 8, 2024
3e72f88
Fix syntax error in commented AccessRoles and update docstring to inc…
NeonDaniel Nov 8, 2024
af9baf2
Refactor `read` requests to accept a token for auth
NeonDaniel Nov 12, 2024
131c5fa
Update docstring for AccessRoles special roles
NeonDaniel Nov 19, 2024
a278b19
Refactor to remove `RW_USERS` role since the `USER` and `ADMIN` roles…
NeonDaniel Nov 19, 2024
d4bd68a
Refactor imports to resolve circular import errors
NeonDaniel Nov 20, 2024
f243426
Remove unused import
NeonDaniel Nov 20, 2024
7c0da8f
Troubleshoot module reloading in unit tests
NeonDaniel Nov 20, 2024
bf941ca
Add check to ensure `HanaToken` is defined for `User` object with uni…
NeonDaniel Nov 20, 2024
c37eca5
Add explicit import of `HanaToken`
NeonDaniel Nov 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions neon_data_models/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 77 additions & 8 deletions neon_data_models/models/api/mq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__]
3 changes: 1 addition & 2 deletions neon_data_models/models/base/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions neon_data_models/models/base/messagebus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
38 changes: 29 additions & 9 deletions neon_data_models/models/user/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()))
Expand All @@ -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()
Expand Down
100 changes: 92 additions & 8 deletions tests/models/api/test_mq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
21 changes: 17 additions & 4 deletions tests/models/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading