Skip to content

Commit 4c89c4b

Browse files
authored
refactor: merge UserInfo and UserWithNamespace (#362)
1 parent d69af01 commit 4c89c4b

15 files changed

Lines changed: 626 additions & 260 deletions

File tree

components/renku_data_services/app_config/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
from renku_data_services.users.db import UserRepo as KcUserRepo
6464
from renku_data_services.users.dummy_kc_api import DummyKeycloakAPI
6565
from renku_data_services.users.kc_api import IKeycloakAPI, KeycloakAPI
66-
from renku_data_services.users.models import UserInfo
66+
from renku_data_services.users.models import UnsavedUserInfo
6767
from renku_data_services.utils.core import merge_api_specs, oidc_discovery
6868

6969
default_resource_pool = models.ResourcePool(
@@ -436,8 +436,8 @@ def from_env(cls, prefix: str = "") -> "Config":
436436
user_store = DummyUserStore(user_always_exists=user_always_exists)
437437
gitlab_client = DummyGitlabAPI()
438438
dummy_users = [
439-
UserInfo("user1", "user1", "doe", "user1@doe.com"),
440-
UserInfo("user2", "user2", "doe", "user2@doe.com"),
439+
UnsavedUserInfo(id="user1", first_name="user1", last_name="doe", email="user1@doe.com"),
440+
UnsavedUserInfo(id="user2", first_name="user2", last_name="doe", email="user2@doe.com"),
441441
]
442442
kc_api = DummyKeycloakAPI(users=[i._to_keycloak_dict() for i in dummy_users])
443443
redis = RedisConfig.fake()

components/renku_data_services/authz/authz.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from renku_data_services.errors import errors
3636
from renku_data_services.namespace.models import Group, GroupUpdate, Namespace, NamespaceKind, NamespaceUpdate
3737
from renku_data_services.project.models import Project, ProjectUpdate
38-
from renku_data_services.users.models import UserWithNamespace, UserWithNamespaceUpdate
38+
from renku_data_services.users.models import UserInfo, UserInfoUpdate
3939

4040
_P = ParamSpec("_P")
4141

@@ -51,7 +51,7 @@ def authz(self) -> "Authz":
5151

5252
_AuthzChangeFuncResult = TypeVar(
5353
"_AuthzChangeFuncResult",
54-
bound=Project | ProjectUpdate | Group | UserWithNamespaceUpdate | list[UserWithNamespace] | None,
54+
bound=Project | ProjectUpdate | Group | UserInfoUpdate | list[UserInfo] | None,
5555
)
5656
_T = TypeVar("_T")
5757
_WithAuthz = TypeVar("_WithAuthz", bound=WithAuthz)
@@ -493,14 +493,14 @@ async def _get_authz_change(
493493
case AuthzOperation.delete, ResourceType.group if result is None:
494494
# NOTE: This means that the group does not exist in the first place so nothing was deleted
495495
pass
496-
case AuthzOperation.update_or_insert, ResourceType.user if isinstance(result, UserWithNamespaceUpdate):
496+
case AuthzOperation.update_or_insert, ResourceType.user if isinstance(result, UserInfoUpdate):
497497
if result.old is None:
498498
authz_change = db_repo.authz._add_user_namespace(result.new.namespace)
499499
case AuthzOperation.insert_many, ResourceType.user_namespace if isinstance(result, list):
500500
for res in result:
501-
if not isinstance(res, UserWithNamespace):
501+
if not isinstance(res, UserInfo):
502502
raise errors.ProgrammingError(
503-
message="Expected list of UserWithNamespace when generating authorization "
503+
message="Expected list of UserInfo when generating authorization "
504504
f"database updates for inserting namespaces but found {type(res)}"
505505
)
506506
authz_change.extend(db_repo.authz._add_user_namespace(res.namespace))

components/renku_data_services/base_models/core.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unicodedata
55
from dataclasses import dataclass, field
66
from enum import Enum, StrEnum
7-
from typing import ClassVar, Optional, Protocol
7+
from typing import ClassVar, Optional, Protocol, Self
88

99
from sanic import Request
1010

@@ -122,7 +122,7 @@ def __init__(self, value: str) -> None:
122122
object.__setattr__(self, "value", value.lower())
123123

124124
@classmethod
125-
def from_name(cls, name: str) -> "Slug":
125+
def from_name(cls, name: str) -> Self:
126126
"""Takes a name with any amount of invalid characters and transforms it in a valid slug."""
127127
lower_case = name.lower()
128128
no_space = re.sub(r"\s+", "-", lower_case)
@@ -139,6 +139,24 @@ def from_name(cls, name: str) -> "Slug":
139139
)
140140
return cls(no_dot_git_or_dot_atom_at_end)
141141

142+
@classmethod
143+
def from_user(cls, email: str | None, first_name: str | None, last_name: str | None, keycloak_id: str) -> Self:
144+
"""Create a slug from a user."""
145+
if email:
146+
slug = email.split("@")[0]
147+
elif first_name and last_name:
148+
slug = first_name + "-" + last_name
149+
elif last_name:
150+
slug = last_name
151+
elif first_name:
152+
slug = first_name
153+
else:
154+
slug = "user_" + keycloak_id
155+
# The length limit is 99 but leave some space for modifications that may be added down the line
156+
# to filter out invalid characters or to generate a unique name
157+
slug = slug[:80]
158+
return cls.from_name(slug)
159+
142160
def __true_div__(self, other: "Slug") -> str:
143161
"""Joins two slugs into a path fraction without dashes at the beginning or end."""
144162
if type(self) is not type(other):

components/renku_data_services/crc/blueprints.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from renku_data_services.crc.db import ResourcePoolRepository, UserRepository
1717
from renku_data_services.k8s.quota import QuotaRepository
1818
from renku_data_services.users.db import UserRepo as KcUserRepo
19-
from renku_data_services.users.models import UserWithNamespace
19+
from renku_data_services.users.models import UserInfo
2020

2121

2222
@dataclass(kw_only=True)
@@ -183,10 +183,10 @@ async def _put_post(
183183
self, api_user: base_models.APIUser, resource_pool_id: int, body: apispec.PoolUsersWithId, post: bool = True
184184
) -> HTTPResponse:
185185
user_ids_to_add = set([user.id for user in body.root])
186-
users_checks: list[UserWithNamespace | None] = await asyncio.gather(
186+
users_checks: list[UserInfo | None] = await asyncio.gather(
187187
*[self.kc_user_repo.get_user(id=id) for id in user_ids_to_add]
188188
)
189-
existing_user_ids = set([user.user.id for user in users_checks if user is not None])
189+
existing_user_ids = set([user.id for user in users_checks if user is not None])
190190
if existing_user_ids != user_ids_to_add:
191191
missing_ids = user_ids_to_add.difference(existing_user_ids)
192192
raise errors.MissingResourceError(message=f"The users with IDs {missing_ids} cannot be found")

components/renku_data_services/message_queue/converters.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,38 +94,38 @@ def to_events(
9494
class _UserEventConverter:
9595
@staticmethod
9696
def to_events(
97-
user: user_models.UserInfo | user_models.UserWithNamespace | user_models.UserWithNamespaceUpdate,
97+
user: user_models.UserInfo | user_models.UserInfoUpdate | str,
9898
event_type: type[AvroModel] | type[events.AmbiguousEvent],
9999
) -> list[Event]:
100100
match event_type:
101101
case v2.UserAdded | events.InsertUserNamespace:
102-
user = cast(user_models.UserWithNamespace, user)
102+
user = cast(user_models.UserInfo, user)
103103
return [
104104
_make_event(
105105
"user.added",
106106
v2.UserAdded(
107-
id=user.user.id,
108-
firstName=user.user.first_name,
109-
lastName=user.user.last_name,
110-
email=user.user.email,
107+
id=user.id,
108+
firstName=user.first_name,
109+
lastName=user.last_name,
110+
email=user.email,
111111
namespace=user.namespace.slug,
112112
),
113113
)
114114
]
115115
case v2.UserRemoved:
116-
user = cast(user_models.UserInfo, user)
117-
return [_make_event("user.removed", v2.UserRemoved(id=user.id))]
116+
user_id = cast(str, user)
117+
return [_make_event("user.removed", v2.UserRemoved(id=user_id))]
118118
case events.UpdateOrInsertUser:
119-
user = cast(user_models.UserWithNamespaceUpdate, user)
119+
user = cast(user_models.UserInfoUpdate, user)
120120
if user.old is None:
121121
return [
122122
_make_event(
123123
"user.added",
124124
v2.UserAdded(
125-
id=user.new.user.id,
126-
firstName=user.new.user.first_name,
127-
lastName=user.new.user.last_name,
128-
email=user.new.user.email,
125+
id=user.new.id,
126+
firstName=user.new.first_name,
127+
lastName=user.new.last_name,
128+
email=user.new.email,
129129
namespace=user.new.namespace.slug,
130130
),
131131
)
@@ -135,10 +135,10 @@ def to_events(
135135
_make_event(
136136
"user.updated",
137137
v2.UserUpdated(
138-
id=user.new.user.id,
139-
firstName=user.new.user.first_name,
140-
lastName=user.new.user.last_name,
141-
email=user.new.user.email,
138+
id=user.new.id,
139+
firstName=user.new.first_name,
140+
lastName=user.new.last_name,
141+
email=user.new.email,
142142
namespace=user.new.namespace.slug,
143143
),
144144
)
@@ -328,16 +328,16 @@ def to_events(input: _T, event_type: type[AvroModel] | type[events.AmbiguousEven
328328
group_authz = cast(list[authz_models.MembershipChange], input)
329329
return _GroupAuthzEventConverter.to_events(group_authz)
330330
case v2.UserAdded:
331-
user_with_namespace = cast(user_models.UserWithNamespace, input)
331+
user_with_namespace = cast(user_models.UserInfo, input)
332332
return _UserEventConverter.to_events(user_with_namespace, event_type)
333333
case v2.UserRemoved:
334-
user_info = cast(user_models.UserInfo, input)
334+
user_info = cast(str, input)
335335
return _UserEventConverter.to_events(user_info, event_type)
336336
case events.UpdateOrInsertUser:
337-
user_with_namespace_update = cast(user_models.UserWithNamespaceUpdate, input)
337+
user_with_namespace_update = cast(user_models.UserInfoUpdate, input)
338338
return _UserEventConverter.to_events(user_with_namespace_update, event_type)
339339
case events.InsertUserNamespace:
340-
user_namespaces = cast(list[user_models.UserWithNamespace], input)
340+
user_namespaces = cast(list[user_models.UserInfo], input)
341341
output: list[Event] = []
342342
for namespace in user_namespaces:
343343
output.extend(_UserEventConverter.to_events(namespace, event_type))

components/renku_data_services/namespace/db.py

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,27 @@ def __init__(
4949
@with_db_transaction
5050
@Authz.authz_change(AuthzOperation.insert_many, ResourceType.user_namespace)
5151
@dispatch_message(events.InsertUserNamespace)
52-
async def generate_user_namespaces(
53-
self, *, session: AsyncSession | None = None
54-
) -> list[user_models.UserWithNamespace]:
52+
async def generate_user_namespaces(self, *, session: AsyncSession | None = None) -> list[user_models.UserInfo]:
5553
"""Generate user namespaces if the user table has data and the namespaces table is empty."""
5654
if not session:
5755
raise errors.ProgrammingError(message="A database session is required")
5856
# NOTE: lock to make sure another instance of the data service cannot insert/update but can read
59-
output: list[user_models.UserWithNamespace] = []
57+
output: list[user_models.UserInfo] = []
6058
await session.execute(text("LOCK TABLE common.namespaces IN EXCLUSIVE MODE"))
6159
at_least_one_namespace = (await session.execute(select(schemas.NamespaceORM).limit(1))).one_or_none()
6260
if at_least_one_namespace:
6361
logger.info("Found at least one user namespace, skipping creation")
64-
return output
62+
return []
6563
logger.info("Found zero user namespaces, will try to create them from users table")
6664
res = await session.scalars(select(user_schemas.UserORM))
6765
for user in res:
68-
ns = await self._insert_user_namespace(session, user, retry_enumerate=10, retry_random=True)
66+
slug = base_models.Slug.from_user(user.email, user.first_name, user.last_name, user.keycloak_id)
67+
ns = await self._insert_user_namespace(
68+
session, user.keycloak_id, slug.value, retry_enumerate=10, retry_random=True
69+
)
6970
logger.info(f"Creating user namespace {ns}")
70-
output.append(user_models.UserWithNamespace(user.dump(), ns))
71+
user.namespace = ns
72+
output.append(user.dump())
7173
logger.info(f"Created {len(output)} user namespaces")
7274
return output
7375

@@ -329,7 +331,7 @@ async def get_namespaces(
329331
output.append(ns_orm.dump())
330332
return output, group_count
331333

332-
async def _get_user_namespaces(self) -> AsyncGenerator[user_models.UserWithNamespace, None]:
334+
async def _get_user_namespaces(self) -> AsyncGenerator[user_models.UserInfo, None]:
333335
"""Lists all user namespaces without regard for authorization or permissions, used for migrations."""
334336
async with self.session_maker() as session, session.begin():
335337
namespaces = await session.stream_scalars(
@@ -379,39 +381,37 @@ async def get_user_namespace(self, user_id: str) -> models.Namespace | None:
379381
raise errors.ProgrammingError(message="Found a namespace that has no user associated with it.")
380382
return ns.dump()
381383

384+
async def _create_user_namespace_slug(
385+
self, session: AsyncSession, user_slug: str, retry_enumerate: int = 0, retry_random: bool = False
386+
) -> str:
387+
"""Create a valid namespace slug for a user."""
388+
nss = await session.scalars(
389+
select(schemas.NamespaceORM.slug).where(schemas.NamespaceORM.slug.startswith(user_slug))
390+
)
391+
nslist = nss.all()
392+
if user_slug not in nslist:
393+
return user_slug
394+
if retry_enumerate:
395+
for inc in range(1, retry_enumerate + 1):
396+
slug = f"{user_slug}-{inc}"
397+
if slug not in nslist:
398+
return slug
399+
if retry_random:
400+
suffix = "".join([random.choice(string.ascii_lowercase + string.digits) for _ in range(8)]) # nosec B311
401+
slug = f"{user_slug}-{suffix}"
402+
if slug not in nslist:
403+
return slug
404+
405+
raise errors.ValidationError(message=f"Cannot create generate a unique namespace slug for the user {user_slug}")
406+
382407
async def _insert_user_namespace(
383-
self, session: AsyncSession, user: schemas.UserORM, retry_enumerate: int = 0, retry_random: bool = False
384-
) -> models.Namespace:
408+
self, session: AsyncSession, user_id: str, user_slug: str, retry_enumerate: int = 0, retry_random: bool = False
409+
) -> schemas.NamespaceORM:
385410
"""Insert a new namespace for the user and optionally retry different variations to avoid collisions."""
386-
original_slug = user.to_slug()
387-
for inc in range(0, retry_enumerate + 1):
388-
# NOTE: on iteration 0 we try with the optimal slug value derived from the user data without any suffix.
389-
suffix = ""
390-
if inc > 0:
391-
suffix = f"-{inc}"
392-
slug = base_models.Slug.from_name(original_slug.value.lower() + suffix)
393-
ns = schemas.NamespaceORM(slug.value, user_id=user.keycloak_id)
394-
try:
395-
async with session.begin_nested():
396-
session.add(ns)
397-
await session.flush()
398-
except IntegrityError:
399-
if retry_enumerate == 0:
400-
raise errors.ValidationError(message=f"The user namespace slug {slug.value} already exists")
401-
continue
402-
else:
403-
await session.refresh(ns)
404-
return ns.dump()
405-
if not retry_random:
406-
raise errors.ValidationError(
407-
message=f"Cannot create generate a unique namespace slug for the user with ID {user.keycloak_id}"
408-
)
409-
# NOTE: At this point the attempts to generate unique ID have ended and the only option is
410-
# to add a small random suffix to avoid uniqueness constraints problems
411-
suffix = "-" + "".join([random.choice(string.ascii_lowercase + string.digits) for _ in range(8)]) # nosec: B311
412-
slug = base_models.Slug.from_name(original_slug.value.lower() + suffix)
413-
ns = schemas.NamespaceORM(slug.value, user_id=user.keycloak_id)
411+
namespace = await self._create_user_namespace_slug(session, user_slug, retry_enumerate, retry_random)
412+
slug = base_models.Slug.from_name(namespace)
413+
ns = schemas.NamespaceORM(slug.value, user_id=user_id)
414414
session.add(ns)
415415
await session.flush()
416416
await session.refresh(ns)
417-
return ns.dump()
417+
return ns

components/renku_data_services/namespace/orm.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""SQLAlchemy's schemas for the group database."""
22

33
from datetime import datetime
4-
from typing import Optional
4+
from typing import Optional, Self, cast
55

66
from sqlalchemy import CheckConstraint, DateTime, MetaData, String, func
77
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship
@@ -11,7 +11,7 @@
1111
from renku_data_services.base_orm.registry import COMMON_ORM_REGISTRY
1212
from renku_data_services.errors import errors
1313
from renku_data_services.namespace import models
14-
from renku_data_services.users.models import UserInfo, UserWithNamespace
14+
from renku_data_services.users.models import UserInfo
1515
from renku_data_services.users.orm import UserORM
1616
from renku_data_services.utils.sqlalchemy import ULIDType
1717

@@ -103,16 +103,33 @@ def dump(self) -> models.Namespace:
103103
name=name,
104104
)
105105

106-
def dump_user(self) -> UserWithNamespace:
106+
def dump_user(self) -> UserInfo:
107107
"""Create a user with namespace from the ORM."""
108108
if self.user is None:
109109
raise errors.ProgrammingError(
110110
message="Cannot dump ORM namespace as namespace with user if the namespace "
111111
"has no associated user with it."
112112
)
113113
ns = self.dump()
114-
user_info = UserInfo(self.user.keycloak_id, self.user.first_name, self.user.last_name, self.user.email)
115-
return UserWithNamespace(user_info, ns)
114+
user_info = UserInfo(
115+
id=self.user.keycloak_id,
116+
first_name=self.user.first_name,
117+
last_name=self.user.last_name,
118+
email=self.user.email,
119+
namespace=ns,
120+
)
121+
return user_info
122+
123+
@classmethod
124+
def load(cls, ns: models.Namespace) -> Self:
125+
"""Create an ORM object from the user object."""
126+
match ns.kind:
127+
case models.NamespaceKind.group:
128+
return cls(slug=ns.slug, group_id=cast(ULID, ns.underlying_resource_id))
129+
case models.NamespaceKind.user:
130+
return cls(slug=ns.slug, user_id=cast(str, ns.underlying_resource_id))
131+
132+
raise errors.ValidationError(message=f"Unknown namespace kind {ns.kind}")
116133

117134

118135
class NamespaceOldORM(BaseORM):

0 commit comments

Comments
 (0)