From 2a7445687f72ecc5f6c7577c3d93b1b98348e0b1 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 15:53:04 -0800 Subject: [PATCH 01/13] 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 --- neon_data_models/enum.py | 8 ++- neon_data_models/models/api/jwt.py | 78 ++++++++++++++++++++++++ neon_data_models/models/user/database.py | 43 ++++++++++--- tests/models/test_user.py | 30 +++++++++ 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 neon_data_models/models/api/jwt.py diff --git a/neon_data_models/enum.py b/neon_data_models/enum.py index 305fb6f..3fcd4ba 100644 --- a/neon_data_models/enum.py +++ b/neon_data_models/enum.py @@ -38,9 +38,11 @@ class AccessRoles(IntEnum): NONE = 0 GUEST = 1 USER = 2 - ADMIN = 3 - OWNER = 4 - + # 3-5 Reserved for "premium users" + ADMIN = 6 + # 7-8 Reserved for "restricted owners" + OWNER = 9 + # 10 Reserved for "unlimited access" NODE = -1 diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py new file mode 100644 index 0000000..20c4640 --- /dev/null +++ b/neon_data_models/models/api/jwt.py @@ -0,0 +1,78 @@ +# 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 typing import Optional, List, Literal +from uuid import uuid4 + +from pydantic import Field +from neon_data_models.enum import AccessRoles +from neon_data_models.models.base import BaseModel + + +class JWT(BaseModel): + iss: Optional[str] = Field(None, description="Token issuer") + sub: Optional[str] = Field(None, + description="Unique token subject, ie a user ID") + exp: int = Field(None, + description="Expiration time in epoch seconds") + iat: int = Field(None, + description="Token creation time in epoch seconds") + jti: str = Field(description="Unique token identifier", + default_factory=lambda: str(uuid4())) + + client_id: str = Field(None, description="Client identifier") + roles: List[str] = Field(None, + description="List of roles, " + "formatted as ` `. " + "See PermissionsConfig for role names") + + +class HanaToken(JWT): + def __init__(self, **kwargs): + permissions = kwargs.get("permissions") + if permissions and isinstance(permissions, dict): + core_permissions = AccessRoles.GUEST if \ + permissions.get("assist") else AccessRoles.NONE + diana_permissions = AccessRoles.GUEST if \ + permissions.get("backend") else AccessRoles.NONE + node_permissions = AccessRoles.USER if \ + permissions.get("node") else AccessRoles.NONE + kwargs["roles"] = [f"core {core_permissions.value}", + f"diana {diana_permissions.value}", + f"node {node_permissions.value}"] + if kwargs.get("expire") and isinstance(kwargs["expire"], float): + kwargs["expire"] = round(kwargs["expire"]) + BaseModel.__init__(self, **kwargs) + + # JWT public parameters + sub: str = Field(None, alias="username", + description="Unique User ID. For backwards-compat, " + "this may be a username.") + exp: int = Field(None, alias="expire", + description="Expiration time in epoch seconds") + + # Private parameters + purpose: Literal["access", "refresh"] = "access" diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index dd842c0..513ccde 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -124,22 +124,47 @@ class PermissionsConfig(BaseModel): class Config: use_enum_values = True + @classmethod + def from_roles(cls, roles: List[str]): + """ + Parse PermissionsConfig from standard JWT roles configuration. + """ + kwargs = {} + for role in roles: + name, value = role.split(' ') + kwargs[name] = AccessRoles[value.upper()] + return cls(**kwargs) + + def to_roles(self): + """ + Dump a PermissionsConfig to standard JWT roles to be included in a JWT. + """ + roles = [] + for key, val in self.model_dump().items(): + roles.append(f"{key} {AccessRoles(val).name}") + return roles + class TokenConfig(BaseModel): - username: str - client_id: str - permissions: Dict[str, bool] - refresh_token: str - expiration: int = Field( - description="Unix timestamp of auth token expiration") + """ + Data model for storing token data in the user database. Note that the actual + tokens are not included here, only metadata used to validate or invalidate a + token and present a list of issued tokens to the user. + """ + token_name: str = Field(description="Human-readable token identifier") + token_id: str = Field(description="Unique token identifier", alias="jti") + user_id: str = Field(description="User ID the token is associated with", + alias="sub") + client_id: str = Field(description="Client ID the token is associated with") + permissions: PermissionsConfig = Field( + description="Permissions for this token " + "(overrides user-level permissions)") refresh_expiration: int = Field( description="Unix timestamp of refresh token expiration") - token_name: str creation_timestamp: int = Field( - description="Unix timestamp of auth token creation") + description="Unix timestamp of refresh token creation") last_refresh_timestamp: int = Field( description="Unix timestamp of last auth token refresh") - access_token: Optional[str] = None class User(BaseModel): diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 8762651..c2057d5 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -105,6 +105,36 @@ def test_user(self): self.assertNotEqual(default_user, duplicate_user) self.assertEqual(default_user.tokens, duplicate_user.tokens) + def test_permissions_config(self): + from neon_data_models.models.user.database import PermissionsConfig + from neon_data_models.enum import AccessRoles + + # Test Default + default_config = PermissionsConfig() + for _, value in default_config.model_dump().items(): + self.assertEqual(value, AccessRoles.NONE) + + test_config = PermissionsConfig(klat=AccessRoles.USER, + core=AccessRoles.GUEST, + diana=AccessRoles.GUEST, + node=AccessRoles.NODE, + hub=AccessRoles.NODE, + llm=AccessRoles.NONE) + # Test dump/load + self.assertEqual(PermissionsConfig(**test_config.model_dump()), + test_config) + + # Test to/from roles + roles = test_config.to_roles() + self.assertIsInstance(roles, list) + for role in roles: + self.assertEqual(len(role.split()), 2) + self.assertEqual(PermissionsConfig.from_roles(roles), test_config) + + def test_token_config(self): + from neon_data_models.models.user.database import TokenConfig + # TODO + class TestNeonProfile(TestCase): def test_create(self): From e9be4124f1723e71bad10c2962e4627aa1789a5e Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 16:38:05 -0800 Subject: [PATCH 02/13] Update TokenConfig to better accept alternate named parameters with comment Update unit test to account for token config change --- neon_data_models/models/api/jwt.py | 4 ++-- neon_data_models/models/user/database.py | 16 ++++++++++++---- tests/models/test_user.py | 13 +++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index 20c4640..fe996d9 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -61,8 +61,8 @@ def __init__(self, **kwargs): node_permissions = AccessRoles.USER if \ permissions.get("node") else AccessRoles.NONE kwargs["roles"] = [f"core {core_permissions.value}", - f"diana {diana_permissions.value}", - f"node {node_permissions.value}"] + f"diana {diana_permissions.value}", + f"node {node_permissions.value}"] if kwargs.get("expire") and isinstance(kwargs["expire"], float): kwargs["expire"] = round(kwargs["expire"]) BaseModel.__init__(self, **kwargs) diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index 513ccde..c61c10e 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -151,15 +151,23 @@ class TokenConfig(BaseModel): tokens are not included here, only metadata used to validate or invalidate a token and present a list of issued tokens to the user. """ + def __init__(self, **kwargs): + # The JWT standard uses "jti" and "sub" for encoded values. Outside of + # that context, those keys are not very descriptive, so we use our own + if jti := kwargs.get("jti"): + kwargs.setdefault("token_id", jti) + if sub := kwargs.get("sub"): + kwargs.setdefault("user_id", sub) + BaseModel.__init__(self, **kwargs) + token_name: str = Field(description="Human-readable token identifier") - token_id: str = Field(description="Unique token identifier", alias="jti") - user_id: str = Field(description="User ID the token is associated with", - alias="sub") + token_id: str = Field(description="Unique token identifier") + user_id: str = Field(description="User ID the token is associated with") client_id: str = Field(description="Client ID the token is associated with") permissions: PermissionsConfig = Field( description="Permissions for this token " "(overrides user-level permissions)") - refresh_expiration: int = Field( + refresh_expiration_timestamp: int = Field( description="Unix timestamp of refresh token expiration") creation_timestamp: int = Field( description="Unix timestamp of refresh token creation") diff --git a/tests/models/test_user.py b/tests/models/test_user.py index c2057d5..356cf72 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -26,6 +26,8 @@ from time import time from unittest import TestCase +from uuid import uuid4 + from pydantic import ValidationError from datetime import date from neon_data_models.models.user.database import NeonUserConfig, TokenConfig, User @@ -83,13 +85,12 @@ def test_neon_user_config(self): def test_user(self): user_kwargs = dict(username="test", password_hash="test", - tokens=[{"username": "test", - "client_id": "test_id", + tokens=[{"token_name": "test_token", + "token_id": str(uuid4()), + "user_id": str(uuid4()), + "client_id": str(uuid4()), "permissions": {}, - "refresh_token": "", - "expiration": round(time()), - "refresh_expiration": round(time()), - "token_name": "test_token", + "refresh_expiration_timestamp": round(time()), "creation_timestamp": round(time()), "last_refresh_timestamp": round(time())}]) default_user = User(**user_kwargs) From ad5ff48b4d02653c2a334571b3b6c6126c261aa0 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 18:09:36 -0800 Subject: [PATCH 03/13] Remove aliased params in `HanaToken` class --- neon_data_models/models/api/jwt.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index fe996d9..aa6a53c 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -67,12 +67,5 @@ def __init__(self, **kwargs): kwargs["expire"] = round(kwargs["expire"]) BaseModel.__init__(self, **kwargs) - # JWT public parameters - sub: str = Field(None, alias="username", - description="Unique User ID. For backwards-compat, " - "this may be a username.") - exp: int = Field(None, alias="expire", - description="Expiration time in epoch seconds") - # Private parameters purpose: Literal["access", "refresh"] = "access" From 52663fabd4938fc6922706385a3c57bd6b3a57c0 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 14:18:07 -0800 Subject: [PATCH 04/13] Fix syntax error in annotated fields --- neon_data_models/models/api/node_v1/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/neon_data_models/models/api/node_v1/__init__.py b/neon_data_models/models/api/node_v1/__init__.py index 6ca1cc4..3440595 100644 --- a/neon_data_models/models/api/node_v1/__init__.py +++ b/neon_data_models/models/api/node_v1/__init__.py @@ -80,8 +80,8 @@ class TextInputData(BaseModel): class NodeKlatResponse(BaseMessage): msg_type: Literal["klat.response"] = "klat.response" - data: Dict[str, KlatResponse] = Field(type=Dict[str, KlatResponse], - description="dict of BCP-47 language: KlatResponse") + data: Dict[str, KlatResponse] = Field( + description="dict of BCP-47 language: KlatResponse") class NodeAudioInputResponse(BaseMessage): @@ -97,8 +97,7 @@ class NodeGetSttResponse(BaseMessage): class NodeGetTtsResponse(BaseMessage): msg_type: Literal["neon.get_tts.response"] = "neon.get_tts.response" data: Dict[str, KlatResponse] = ( - Field(type=Dict[str, KlatResponse], - description="dict of BCP-47 language: KlatResponse")) + Field(description="dict of BCP-47 language: KlatResponse")) class CoreWWDetected(BaseMessage): From d010958df40690da29fc4c12a2cf52fd8f84a560 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 15:48:01 -0800 Subject: [PATCH 05/13] Update token timestamp descriptions for accuracy --- neon_data_models/models/user/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index c61c10e..d6bcebc 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -170,9 +170,9 @@ def __init__(self, **kwargs): refresh_expiration_timestamp: int = Field( description="Unix timestamp of refresh token expiration") creation_timestamp: int = Field( - description="Unix timestamp of refresh token creation") + description="Unix timestamp of token creation (auth+refresh)") last_refresh_timestamp: int = Field( - description="Unix timestamp of last auth token refresh") + description="Unix timestamp of last token issuance (auth+refresh)") class User(BaseModel): From 2929b6257810d26f1260f760fec3a9596c2d2426 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 15:58:47 -0800 Subject: [PATCH 06/13] Update TokenConfig to handle JWT standard field names Add test coverage for TokenConfig --- neon_data_models/models/user/database.py | 4 +++ tests/models/test_user.py | 33 ++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index d6bcebc..af8d811 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -158,6 +158,10 @@ def __init__(self, **kwargs): kwargs.setdefault("token_id", jti) if sub := kwargs.get("sub"): kwargs.setdefault("user_id", sub) + if iat := kwargs.get("iat"): + kwargs.setdefault("creation_timestamp", iat) + if exp := kwargs.get("exp"): + kwargs.setdefault("refresh_expiration_timestamp", exp) BaseModel.__init__(self, **kwargs) token_name: str = Field(description="Human-readable token identifier") diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 356cf72..e6ab42d 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -133,8 +133,37 @@ def test_permissions_config(self): self.assertEqual(PermissionsConfig.from_roles(roles), test_config) def test_token_config(self): - from neon_data_models.models.user.database import TokenConfig - # TODO + from neon_data_models.models.user.database import PermissionsConfig + token_id = str(uuid4()) + user_id = str(uuid4()) + client_id = str(uuid4()) + token_name = "Test Token" + permissions = PermissionsConfig() + refresh_expiration = round(time()) + 3600 + creation = round(time()) - 3600 + last_refresh = round(time()) + + from_database = TokenConfig(token_name=token_name, + token_id=token_id, + user_id=user_id, + client_id=client_id, + permissions=permissions, + refresh_expiration_timestamp=refresh_expiration, + creation_timestamp=creation, + last_refresh_timestamp=last_refresh) + + from_token = TokenConfig(jti=token_id, + sub=user_id, + iat=creation, + exp=refresh_expiration, + token_name=token_name, + client_id=client_id, + permissions=permissions, + last_refresh_timestamp=last_refresh) + + self.assertEqual(from_database, from_token) + self.assertEqual(from_database.model_dump_json(), + from_token.model_dump_json()) class TestNeonProfile(TestCase): From da6b79ffe3ac64686404063d95ff85bf1abcaa60 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 16:05:17 -0800 Subject: [PATCH 07/13] Update comment in TokenConfig --- neon_data_models/models/user/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index af8d811..3ce64a1 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -152,8 +152,8 @@ class TokenConfig(BaseModel): token and present a list of issued tokens to the user. """ def __init__(self, **kwargs): - # The JWT standard uses "jti" and "sub" for encoded values. Outside of - # that context, those keys are not very descriptive, so we use our own + # The JWT standard uses these standard keys; outside of that context, + # they are not very descriptive, so the database uses its own schema if jti := kwargs.get("jti"): kwargs.setdefault("token_id", jti) if sub := kwargs.get("sub"): From cd507ae6eaee5e168f20933c07a73e52d7376826 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 17:30:56 -0800 Subject: [PATCH 08/13] Mark `TokenConfig` as deprecated Refactor `HanaToken` to include params previously used in `TokenConfig` --- neon_data_models/models/api/jwt.py | 10 ++++- neon_data_models/models/user/database.py | 47 +++++++++--------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index aa6a53c..2f890bb 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -52,8 +52,11 @@ class JWT(BaseModel): class HanaToken(JWT): def __init__(self, **kwargs): + from neon_data_models.models.user import PermissionsConfig permissions = kwargs.get("permissions") - if permissions and isinstance(permissions, dict): + if permissions and isinstance(permissions, PermissionsConfig): + kwargs["roles"] = permissions.to_roles() + elif permissions and isinstance(permissions, dict): core_permissions = AccessRoles.GUEST if \ permissions.get("assist") else AccessRoles.NONE diana_permissions = AccessRoles.GUEST if \ @@ -68,4 +71,9 @@ def __init__(self, **kwargs): BaseModel.__init__(self, **kwargs) # Private parameters + token_name: str = "" + last_refresh_timestamp: Optional[int] = None purpose: Literal["access", "refresh"] = "access" + + +__all__ = [JWT.__name__, HanaToken.__name__] diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index 3ce64a1..0b63cdd 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -27,6 +27,8 @@ from time import time from typing import Dict, Any, List, Literal, Optional from uuid import uuid4 + +from neon_data_models.models.api.jwt import HanaToken from neon_data_models.models.base import BaseModel from pydantic import Field from datetime import date @@ -146,37 +148,23 @@ def to_roles(self): class TokenConfig(BaseModel): - """ - Data model for storing token data in the user database. Note that the actual - tokens are not included here, only metadata used to validate or invalidate a - token and present a list of issued tokens to the user. - """ - def __init__(self, **kwargs): - # The JWT standard uses these standard keys; outside of that context, - # they are not very descriptive, so the database uses its own schema - if jti := kwargs.get("jti"): - kwargs.setdefault("token_id", jti) - if sub := kwargs.get("sub"): - kwargs.setdefault("user_id", sub) - if iat := kwargs.get("iat"): - kwargs.setdefault("creation_timestamp", iat) - if exp := kwargs.get("exp"): - kwargs.setdefault("refresh_expiration_timestamp", exp) - BaseModel.__init__(self, **kwargs) - - token_name: str = Field(description="Human-readable token identifier") - token_id: str = Field(description="Unique token identifier") - user_id: str = Field(description="User ID the token is associated with") - client_id: str = Field(description="Client ID the token is associated with") - permissions: PermissionsConfig = Field( - description="Permissions for this token " - "(overrides user-level permissions)") - refresh_expiration_timestamp: int = Field( + from ovos_utils.log import log_deprecation + log_deprecation("Use `neon_data_models.models.api.jwt.HanaToken`", + "0.0.1") + username: str + client_id: str + permissions: Dict[str, bool] + refresh_token: str + expiration: int = Field( + description="Unix timestamp of auth token expiration") + refresh_expiration: int = Field( description="Unix timestamp of refresh token expiration") + token_name: str creation_timestamp: int = Field( description="Unix timestamp of token creation (auth+refresh)") last_refresh_timestamp: int = Field( - description="Unix timestamp of last token issuance (auth+refresh)") + description="Unix timestamp of last token refresh (auth+refresh)") + access_token: Optional[str] = None class User(BaseModel): @@ -188,12 +176,11 @@ class User(BaseModel): klat: KlatConfig = KlatConfig() llm: BrainForgeConfig = BrainForgeConfig() permissions: PermissionsConfig = PermissionsConfig() - tokens: Optional[List[TokenConfig]] = [] + tokens: Optional[List[HanaToken]] = [] def __eq__(self, other): return self.model_dump() == other.model_dump() __all__ = [NeonUserConfig.__name__, KlatConfig.__name__, - BrainForgeConfig.__name__, PermissionsConfig.__name__, - TokenConfig.__name__, User.__name__] + BrainForgeConfig.__name__, PermissionsConfig.__name__, User.__name__] From a05822c421a612b8702f3e405321d062d9f571b6 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 17:32:24 -0800 Subject: [PATCH 09/13] Update tests to reflect changes to `HanaToken` --- tests/models/test_user.py | 40 ++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/tests/models/test_user.py b/tests/models/test_user.py index e6ab42d..95d8836 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -30,7 +30,9 @@ from pydantic import ValidationError from datetime import date -from neon_data_models.models.user.database import NeonUserConfig, TokenConfig, User + +from neon_data_models.models.api.jwt import HanaToken +from neon_data_models.models.user.database import NeonUserConfig, User class TestDatabase(TestCase): @@ -94,7 +96,7 @@ def test_user(self): "creation_timestamp": round(time()), "last_refresh_timestamp": round(time())}]) default_user = User(**user_kwargs) - self.assertIsInstance(default_user.tokens[0], TokenConfig) + self.assertIsInstance(default_user.tokens[0], HanaToken) with self.assertRaises(ValidationError): User() @@ -143,23 +145,23 @@ def test_token_config(self): creation = round(time()) - 3600 last_refresh = round(time()) - from_database = TokenConfig(token_name=token_name, - token_id=token_id, - user_id=user_id, - client_id=client_id, - permissions=permissions, - refresh_expiration_timestamp=refresh_expiration, - creation_timestamp=creation, - last_refresh_timestamp=last_refresh) - - from_token = TokenConfig(jti=token_id, - sub=user_id, - iat=creation, - exp=refresh_expiration, - token_name=token_name, - client_id=client_id, - permissions=permissions, - last_refresh_timestamp=last_refresh) + from_database = HanaToken(token_name=token_name, + jti=token_id, + sub=user_id, + client_id=client_id, + roles=permissions.to_roles(), + exp=refresh_expiration, + iat=creation, + last_refresh_timestamp=last_refresh) + + from_token = HanaToken(jti=token_id, + sub=user_id, + iat=creation, + exp=refresh_expiration, + token_name=token_name, + client_id=client_id, + permissions=permissions, + last_refresh_timestamp=last_refresh) self.assertEqual(from_database, from_token) self.assertEqual(from_database.model_dump_json(), From f60cd6c88b3fd92fb29938ccdbe026709a456ccc Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 17:53:29 -0800 Subject: [PATCH 10/13] Update token to include initial creation timestamp for database use Annotate fields of HanaToken --- neon_data_models/models/api/jwt.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index 2f890bb..398e95f 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -23,7 +23,7 @@ # 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 time import time from typing import Optional, List, Literal from uuid import uuid4 @@ -71,8 +71,16 @@ def __init__(self, **kwargs): BaseModel.__init__(self, **kwargs) # Private parameters - token_name: str = "" - last_refresh_timestamp: Optional[int] = None + token_name: str = Field(default="", + description="Friendly name to identify this token.") + creation_timestamp: int = Field(default_factory=lambda: int(time()), + description="Timestamp of initial token " + "creation (not counting " + "refreshes).") + last_refresh_timestamp: Optional[int] = Field(default=None, + description="Timestamp of " + "most recent " + "refresh.") purpose: Literal["access", "refresh"] = "access" From 39d8d7efb94ebc231e9668cb67e866d479a07d83 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 17:57:48 -0800 Subject: [PATCH 11/13] Update deprecation warning to use builtin decorator --- neon_data_models/models/user/database.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/neon_data_models/models/user/database.py b/neon_data_models/models/user/database.py index 0b63cdd..af6f5fc 100644 --- a/neon_data_models/models/user/database.py +++ b/neon_data_models/models/user/database.py @@ -26,6 +26,7 @@ from time import time from typing import Dict, Any, List, Literal, Optional +from typing_extensions import deprecated from uuid import uuid4 from neon_data_models.models.api.jwt import HanaToken @@ -147,10 +148,8 @@ def to_roles(self): return roles +@deprecated(f"Use `neon_data_models.models.api.jwt.HanaToken`") class TokenConfig(BaseModel): - from ovos_utils.log import log_deprecation - log_deprecation("Use `neon_data_models.models.api.jwt.HanaToken`", - "0.0.1") username: str client_id: str permissions: Dict[str, bool] From 278b2eb4169a3d0ad67cceb487d24f1ffdce8ff3 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 18:08:09 -0800 Subject: [PATCH 12/13] Update JWT base model to remove default values causing false positive validation results Update unit tests to account for token changes --- neon_data_models/models/api/jwt.py | 16 +++++++--------- tests/models/test_user.py | 11 ++++++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index 398e95f..8a433bd 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -33,19 +33,17 @@ class JWT(BaseModel): - iss: Optional[str] = Field(None, description="Token issuer") - sub: Optional[str] = Field(None, + iss: Optional[str] = Field(None, validate_default=True, + description="Token issuer") + sub: Optional[str] = Field(None, validate_default=True, description="Unique token subject, ie a user ID") - exp: int = Field(None, - description="Expiration time in epoch seconds") - iat: int = Field(None, - description="Token creation time in epoch seconds") + exp: int = Field(description="Expiration time in epoch seconds") + iat: int = Field(description="Token creation time in epoch seconds") jti: str = Field(description="Unique token identifier", default_factory=lambda: str(uuid4())) - client_id: str = Field(None, description="Client identifier") - roles: List[str] = Field(None, - description="List of roles, " + client_id: str = Field(description="Client identifier") + roles: List[str] = Field(description="List of roles, " "formatted as ` `. " "See PermissionsConfig for role names") diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 95d8836..c67c862 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -32,7 +32,7 @@ from datetime import date from neon_data_models.models.api.jwt import HanaToken -from neon_data_models.models.user.database import NeonUserConfig, User +from neon_data_models.models.user.database import NeonUserConfig, User, PermissionsConfig class TestDatabase(TestCase): @@ -88,11 +88,12 @@ def test_user(self): user_kwargs = dict(username="test", password_hash="test", tokens=[{"token_name": "test_token", - "token_id": str(uuid4()), - "user_id": str(uuid4()), + "jti": str(uuid4()), + "sub": str(uuid4()), "client_id": str(uuid4()), - "permissions": {}, - "refresh_expiration_timestamp": round(time()), + "roles": PermissionsConfig().to_roles(), + "iat": round(time()) - 1, + "exp": round(time()) + 1, "creation_timestamp": round(time()), "last_refresh_timestamp": round(time())}]) default_user = User(**user_kwargs) From 0b556477392b50a763f06ab8099c2658642d99e2 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 12 Nov 2024 09:05:06 -0800 Subject: [PATCH 13/13] Add jwt imports to api module for consistency with other submodules --- neon_data_models/models/api/__init__.py | 1 + neon_data_models/models/api/jwt.py | 1 + 2 files changed, 2 insertions(+) diff --git a/neon_data_models/models/api/__init__.py b/neon_data_models/models/api/__init__.py index 24ff4a3..5881279 100644 --- a/neon_data_models/models/api/__init__.py +++ b/neon_data_models/models/api/__init__.py @@ -26,3 +26,4 @@ from neon_data_models.models.api.node_v1 import * from neon_data_models.models.api.mq import * +from neon_data_models.models.api.jwt import * diff --git a/neon_data_models/models/api/jwt.py b/neon_data_models/models/api/jwt.py index 8a433bd..61680a3 100644 --- a/neon_data_models/models/api/jwt.py +++ b/neon_data_models/models/api/jwt.py @@ -23,6 +23,7 @@ # 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 time import time from typing import Optional, List, Literal from uuid import uuid4