Skip to content

Commit

Permalink
Update UserDbRequest models (#3)
Browse files Browse the repository at this point in the history
* Add BaseModel for JWT and `HanaToken` (to use for auth and refresh)
Update TokenConfig model to reflect expected database schema
Refactor AccessRoles values to allow for more in-between values
Add methods to read/dump PermissionsConfig to JWT-compatible role strings

* Update TokenConfig to better accept alternate named parameters with comment
Update unit test to account for token config change

* Update TokenConfig to handle JWT standard field names
Add test coverage for TokenConfig

* Update comment in TokenConfig

* Mark `TokenConfig` as deprecated
Refactor `HanaToken` to include params previously used in `TokenConfig`

* Update deprecation warning to use builtin decorator

* Refactors `UserDbRequest` into seprate schemas for CRUD operations
Maintains compat. with users service by determining model based on the requested `operation`

* Add optional authentication to `UpdateUserRequest` model to allow for admin-authenticated changes
Finish test coverage for MQ user database CRUD models

* Update tests to handle refactored model params
Update raised exception to use exact keys

* Refactor `ReadUserRequest` to require some form of authentication for any read operation
Define "READ_USERS" role and annotate special roles

* Fix syntax error in commented AccessRoles and update docstring to include comments

* Refactor `read` requests to accept a token for auth
Validate passed token as an access token, rather than refresh

* Update docstring for AccessRoles special roles
Annotate config values in PermissionsConfig to describe application of permissions
Add a `users` permission to define access to the users service independent of the rest of the backend (matches `node` and `llm` behavior)

* Refactor to remove `RW_USERS` role since the `USER` and `ADMIN` roles already define read and write access, respectively

* Refactor imports to resolve circular import errors
Add import tests to ensure all imports resolve

* Remove unused import
Refactor tests to resolve issue with reloading base classes

* Troubleshoot module reloading in unit tests

* Add check to ensure `HanaToken` is defined for `User` object with unit test
Issue noted in https://github.com/NeonGeckoCom/neon-users-service/actions/runs/11923783196/job/33236966442

* Add explicit import of `HanaToken`
https://github.com/NeonGeckoCom/neon-users-service/actions/runs/11923783196/job/33237158775
  • Loading branch information
NeonDaniel authored Nov 21, 2024
1 parent 9c1c1cf commit db62bab
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 33 deletions.
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

0 comments on commit db62bab

Please sign in to comment.