Skip to content

Commit db62bab

Browse files
authored
Update UserDbRequest models (#3)
* 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
1 parent 9c1c1cf commit db62bab

File tree

8 files changed

+278
-33
lines changed

8 files changed

+278
-33
lines changed

neon_data_models/enum.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class AccessRoles(IntEnum):
3434
non-user roles. In this way, an activity can require, for example,
3535
`permission > AccessRoles.GUEST` to grant access to all registered users,
3636
admins, and owners.
37+
38+
Special Roles:
39+
NODE: Reserved for use by a Node device service account to access
40+
various services
3741
"""
3842
NONE = 0
3943
# 1-9 reserved for unauthenticated connections

neon_data_models/models/api/mq.py

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,86 @@
2424
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
2525
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
2626

27-
from typing import Literal, Optional
27+
from typing import Literal, Optional, Annotated, Union
28+
29+
from pydantic import Field, TypeAdapter, model_validator
30+
31+
from neon_data_models.models.api.jwt import HanaToken
2832
from neon_data_models.models.base.contexts import MQContext
2933
from neon_data_models.models.user.database import User
3034

3135

32-
class UserDbRequest(MQContext):
33-
operation: Literal["create", "read", "update", "delete"]
34-
username: str
35-
password: Optional[str] = None
36-
access_token: Optional[str] = None
37-
user: Optional[User] = None
36+
class CreateUserRequest(MQContext):
37+
operation: Literal["create"] = "create"
38+
user: User = Field(description="User object to create")
39+
40+
41+
class ReadUserRequest(MQContext):
42+
operation: Literal["read"] = "read"
43+
user_spec: str = Field(description="Username or User ID to read")
44+
auth_user_spec: str = Field(
45+
default="", description="Username or ID to authorize database read. "
46+
"If unset, this will use `user_spec`")
47+
access_token: Optional[HanaToken] = Field(
48+
None, description="Token associated with `auth_username`")
49+
password: Optional[str] = Field(None,
50+
description="Password associated with "
51+
"`auth_username`")
52+
53+
@model_validator(mode="after")
54+
def validate_params(self) -> 'ReadUserRequest':
55+
if not self.auth_user_spec:
56+
self.auth_user_spec = self.user_spec
57+
if self.access_token and self.access_token.purpose != "access":
58+
raise ValueError(f"Expected an access token but got: "
59+
f"{self.access_token.purpose}")
60+
return self
61+
62+
63+
class UpdateUserRequest(MQContext):
64+
operation: Literal["update"] = "update"
65+
user: User = Field(description="Updated User object to write to database")
66+
auth_username: str = Field(
67+
default="", description="Username to authorize database change. If "
68+
"unset, this will use `user.username`")
69+
auth_password: str = Field(
70+
default="", description="Password (clear or hashed) associated with "
71+
"`auth_username`. If unset, this will use "
72+
"`user.password_hash`. If changing the "
73+
"password, this must contain the existing "
74+
"password, with the new password specified in "
75+
"`user`")
76+
77+
@model_validator(mode="after")
78+
def get_auth_username(self) -> 'UpdateUserRequest':
79+
if not self.auth_username:
80+
self.auth_username = self.user.username
81+
if not self.auth_password:
82+
self.auth_password = self.user.password_hash
83+
if not all((self.auth_username, self.auth_password)):
84+
raise ValueError("Missing auth_username or auth_password")
85+
return self
86+
87+
88+
class DeleteUserRequest(MQContext):
89+
operation: Literal["delete"] = "delete"
90+
user: User = Field(description="Exact User object to remove from the "
91+
"database")
92+
93+
94+
class UserDbRequest:
95+
"""
96+
Generic class to dynamically build a UserDB CRUD request object based on the
97+
requested `operation`
98+
"""
99+
ta = TypeAdapter(Annotated[Union[CreateUserRequest, ReadUserRequest,
100+
UpdateUserRequest, DeleteUserRequest],
101+
Field(discriminator='operation')])
102+
103+
def __new__(cls, *args, **kwargs):
104+
return cls.ta.validate_python(kwargs)
38105

39106

40-
__all__ = [UserDbRequest.__name__]
107+
__all__ = [CreateUserRequest.__name__, ReadUserRequest.__name__,
108+
UpdateUserRequest.__name__, DeleteUserRequest.__name__,
109+
UserDbRequest.__name__]

neon_data_models/models/base/contexts.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,10 @@
2323
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
2424
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
2525
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
2627
from datetime import datetime, timedelta
2728
from typing import Literal, List, Optional
2829

29-
from pydantic import Field
30-
3130
from neon_data_models.models.base import BaseModel
3231

3332

neon_data_models/models/base/messagebus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
from neon_data_models.models.base import BaseModel
3131
from neon_data_models.models.base.contexts import (SessionContext, KlatContext,
3232
TimingContext, MQContext)
33-
from neon_data_models.models.client import NodeData
34-
from neon_data_models.models.user import NeonUserConfig
33+
from neon_data_models.models.client.node import NodeData
34+
from neon_data_models.models.user.database import NeonUserConfig
3535

3636

3737
class MessageContext(BaseModel):

neon_data_models/models/user/database.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from typing_extensions import deprecated
3030
from uuid import uuid4
3131

32-
from neon_data_models.models.api.jwt import HanaToken
32+
# from neon_data_models.models.api import HanaToken
3333
from neon_data_models.models.base import BaseModel
3434
from pydantic import Field
3535
from datetime import date
@@ -115,14 +115,28 @@ class BrainForgeConfig(BaseModel):
115115

116116
class PermissionsConfig(BaseModel):
117117
"""
118-
Defines roles for supported projects/service families.
118+
Defines roles for supported projects/services.
119119
"""
120-
klat: AccessRoles = AccessRoles.NONE
121-
core: AccessRoles = AccessRoles.NONE
122-
diana: AccessRoles = AccessRoles.NONE
123-
node: AccessRoles = AccessRoles.NONE
124-
hub: AccessRoles = AccessRoles.NONE
125-
llm: AccessRoles = AccessRoles.NONE
120+
klat: AccessRoles = Field(
121+
AccessRoles.NONE, description="Defines access to Klat chat services.")
122+
core: AccessRoles = Field(
123+
AccessRoles.NONE, description="Defines access to Neon core services.")
124+
diana: AccessRoles = Field(
125+
AccessRoles.NONE,
126+
description="Defines access to DIANA backend services. "
127+
"(i.e. API proxy, email proxy).")
128+
users: AccessRoles = Field(
129+
AccessRoles.NONE, description="Defines access to the users service.")
130+
node: AccessRoles = Field(
131+
AccessRoles.NONE,
132+
description="Defines access to the node websocket in HANA.")
133+
hub: AccessRoles = Field(
134+
AccessRoles.NONE, description="Defines access to a hub device.")
135+
llm: AccessRoles = Field(
136+
AccessRoles.NONE,
137+
description="Defines access to the BrainForge LLM backend. Note that "
138+
"per-model permissions may also apply and further restrict "
139+
"a user's access to some models for inference.")
126140

127141
class Config:
128142
use_enum_values = True
@@ -167,6 +181,12 @@ class TokenConfig(BaseModel):
167181

168182

169183
class User(BaseModel):
184+
def __init__(self, **kwargs):
185+
# Ensure `HanaToken` is populated from the import space
186+
from neon_data_models.models.api.jwt import HanaToken
187+
self.model_rebuild()
188+
BaseModel.__init__(self, **kwargs)
189+
170190
username: str
171191
password_hash: Optional[str] = None
172192
user_id: str = Field(default_factory=lambda: str(uuid4()))
@@ -175,7 +195,7 @@ class User(BaseModel):
175195
klat: KlatConfig = KlatConfig()
176196
llm: BrainForgeConfig = BrainForgeConfig()
177197
permissions: PermissionsConfig = PermissionsConfig()
178-
tokens: Optional[List[HanaToken]] = []
198+
tokens: Optional[List['HanaToken']] = []
179199

180200
def __eq__(self, other):
181201
return self.model_dump() == other.model_dump()

tests/models/api/test_mq.py

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,98 @@
3131

3232

3333
class TestMQ(TestCase):
34-
def test_user_db_request(self):
35-
valid_model = UserDbRequest(operation="create", username="test_user",
36-
message_id="test")
37-
self.assertIsInstance(valid_model, UserDbRequest)
34+
def test_create_user_db_request(self):
35+
from neon_data_models.models.api.mq import CreateUserRequest
36+
37+
# Test create user valid
38+
valid_kwargs = {"message_id": "test_id", "operation": "create",
39+
"user": {"username": "test_user"}}
40+
create_request = CreateUserRequest(**valid_kwargs)
41+
self.assertIsInstance(create_request, CreateUserRequest)
42+
generic_request = UserDbRequest(**valid_kwargs)
43+
self.assertIsInstance(generic_request, CreateUserRequest)
44+
self.assertEqual(generic_request.user.username,
45+
create_request.user.username)
46+
47+
# Test invalid
3848
with self.assertRaises(ValidationError):
39-
UserDbRequest(operation="get", username="test", message_id="test")
49+
UserDbRequest(operation="create", username="test",
50+
message_id="test0")
51+
52+
def test_read_user_db_request(self):
53+
from neon_data_models.models.api.mq import ReadUserRequest
54+
55+
# Test read user valid
56+
valid_kwargs = {"message_id": "test_id", "operation": "read",
57+
"user_spec": "test_user"}
58+
read_request = ReadUserRequest(**valid_kwargs)
59+
self.assertIsInstance(read_request, ReadUserRequest)
60+
generic_request = UserDbRequest(**valid_kwargs)
61+
self.assertIsInstance(generic_request, ReadUserRequest)
62+
self.assertEqual(generic_request.user_spec,
63+
read_request.user_spec)
64+
65+
# Test invalid
4066
with self.assertRaises(ValidationError):
41-
UserDbRequest(operation="delete", username="test_user",
42-
user="test_user", message_id="test")
67+
UserDbRequest(operation="read", user={"username": "test"},
68+
message_id="test0")
69+
70+
def test_update_user_db_request(self):
71+
from neon_data_models.models.api.mq import UpdateUserRequest
72+
73+
# Test update user valid
74+
valid_kwargs = {"message_id": "test_id", "operation": "update",
75+
"auth_password": "test_password",
76+
"user": {"username": "test_user",
77+
"skills": {"skill_id": {"test": True}}}}
78+
update_request = UpdateUserRequest(**valid_kwargs)
79+
self.assertIsInstance(update_request, UpdateUserRequest)
80+
self.assertEqual(update_request.auth_username,
81+
update_request.user.username)
82+
generic_request = UserDbRequest(**valid_kwargs)
83+
self.assertIsInstance(generic_request, UpdateUserRequest)
84+
self.assertEqual(generic_request.user.username,
85+
update_request.user.username)
86+
87+
# Test update read username/password from User object
88+
update = UpdateUserRequest(message_id="test_id", operation="update",
89+
user={"username": "user",
90+
"password_hash": "password"})
91+
self.assertEqual(update.auth_username, "user")
92+
self.assertEqual(update.auth_password, "password")
93+
94+
# Test update with separate authentication user
95+
update = UpdateUserRequest(message_id="test_id", operation="update",
96+
user={"username": "user",
97+
"password_hash": "password"},
98+
auth_username="admin", auth_password="admin_pass")
99+
self.assertEqual(update.user.username, "user")
100+
self.assertEqual(update.user.password_hash, "password")
101+
102+
self.assertEqual(update.auth_username, "admin")
103+
self.assertEqual(update.auth_password, "admin_pass")
104+
105+
# Test invalid
106+
with self.assertRaises(ValidationError):
107+
UserDbRequest(operation="update", user={"username": "test_user",
108+
"skills": {"skill_id": {
109+
"test": True}}},
110+
message_id="test0")
111+
112+
def test_delete_user_db_request(self):
113+
from neon_data_models.models.api.mq import DeleteUserRequest
114+
115+
# Test delete user valid
116+
valid_kwargs = {"message_id": "test_id", "operation": "delete",
117+
"user": {"username": "test_user"}}
118+
delete_request = DeleteUserRequest(**valid_kwargs)
119+
self.assertIsInstance(delete_request, DeleteUserRequest)
120+
generic_request = UserDbRequest(**valid_kwargs)
121+
self.assertIsInstance(generic_request, DeleteUserRequest)
122+
self.assertEqual(generic_request.user.username,
123+
delete_request.user.username)
124+
125+
# Test invalid
43126
with self.assertRaises(ValidationError):
44-
UserDbRequest(operation="create", username="test_user")
127+
UserDbRequest(operation="delete", username="test_user",
128+
message_id="test0")

tests/models/test_base.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,10 @@
2727
import importlib
2828
import os
2929
from datetime import datetime, timedelta
30-
3130
from unittest import TestCase
3231
from time import time
3332
from pydantic import ValidationError
3433

35-
from neon_data_models.models.client import NodeData
36-
from neon_data_models.models.user import NeonUserConfig
37-
3834

3935
class TestBaseModel(TestCase):
4036
def test_base_model(self):
@@ -60,6 +56,19 @@ def test_base_model(self):
6056
self.assertEqual(model.model_config["extra"], "ignore")
6157
self.assertEqual(allowed.model_config["extra"], "allow")
6258

59+
# Ensure modules are unloaded for future inheritance tests
60+
import sys
61+
for module in list(sys.modules.keys()):
62+
if module.startswith("neon_data_models"):
63+
del sys.modules[module]
64+
65+
def test_base_model_inheritance(self):
66+
from neon_data_models.models.base import BaseModel
67+
from neon_data_models.models.user.database import PermissionsConfig
68+
config = PermissionsConfig()
69+
self.assertTrue(isinstance(config, BaseModel))
70+
self.assertIsInstance(config.model_config["extra"], str)
71+
6372

6473
class TestContexts(TestCase):
6574
def test_session_context(self):
@@ -135,6 +144,8 @@ def test_mq_context(self):
135144
class TestMessagebus(TestCase):
136145
def test_base_model(self):
137146
from neon_data_models.models.base.messagebus import BaseMessage
147+
from neon_data_models.models.client import NodeData
148+
from neon_data_models.models.user import NeonUserConfig
138149

139150
with self.assertRaises(ValidationError):
140151
BaseMessage()
@@ -162,6 +173,8 @@ def test_base_model(self):
162173

163174
def test_message_context(self):
164175
from neon_data_models.models.base.messagebus import MessageContext
176+
from neon_data_models.models.client import NodeData
177+
from neon_data_models.models.user import NeonUserConfig
165178

166179
# Default Behavior
167180
default_context = MessageContext()

0 commit comments

Comments
 (0)