Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b9215cc
chore: removes missing type check warnings
arnoldknott May 29, 2026
83c33ad
chore: fixes cross-cartesian products for non-hierarchical relationsh…
arnoldknott May 29, 2026
78fa4dc
test: removes tautology in test and ensurees user_profile and user_ac…
arnoldknott May 29, 2026
77615d0
doc: adds documentation of usage and preventing of leaking user_accou…
arnoldknott May 29, 2026
a901c00
doc: code clean-up
arnoldknott May 29, 2026
ae465fd
chore: updates uvicorn and switches the used websockets to websockets…
arnoldknott May 29, 2026
26d064f
chore: exports versions variables in pre-commit script
arnoldknott May 29, 2026
140b3c1
refactor: simplifies call to get microsoft teams as identities
arnoldknott May 29, 2026
5006644
chore: adds all variables in versions.env to build and startup of tes…
arnoldknott May 29, 2026
ab43920
chore: fixes potentially undefined warning
arnoldknott May 29, 2026
0ba701e
chore: removes handling of external identity sources - like Microsoft…
arnoldknott May 29, 2026
d79dba1
chore: fixes formating and linting
arnoldknott May 29, 2026
dc8da59
chore: code cleanup
arnoldknott May 29, 2026
2dbd19a
chore: adds debugging to ueberGroup and renames the field description…
arnoldknott May 30, 2026
0288229
chore: code clean-up
arnoldknott May 30, 2026
5f2a132
chore: fixes the non-appearing linked groups in ueberGroups and remov…
arnoldknott May 30, 2026
820a5e0
chore: updates teh exsiting ToDo list inside UeberGroups for upcoming…
arnoldknott May 30, 2026
10848da
chore: adds relationHandler in frontend to deal with linking, unlinki…
arnoldknott May 30, 2026
07cd5c8
test: adds tests for relationHandler
arnoldknott May 30, 2026
26a1fb3
chore: adds 'order' field to hierarchies in frontend
arnoldknott May 30, 2026
cf4c472
refactor: clearer sepration of concerns between SocketIO and relation…
arnoldknott May 30, 2026
c2c2264
test: adopts tests to refactores socketio and relationHandler classes
arnoldknott May 30, 2026
f4e7816
doc adds a comment on moving the integrations of MSTeams from the APi…
arnoldknott May 30, 2026
0981eaa
doc: adds more comment on the use of api's vs. integrations
arnoldknott May 30, 2026
04491d1
refactor: ueberGroup to use relationHandler
arnoldknott May 30, 2026
616d02a
refactor: instead of passing children data to relationHandler in cons…
arnoldknott May 30, 2026
f1ef191
doc: removes irrelevant comments
arnoldknott May 30, 2026
674b9c6
chore: rename into consequent id syntax
arnoldknott May 31, 2026
e94153f
chore: provides createPending from SocketIO class
arnoldknott May 31, 2026
de3449f
test: adds tests for providePending
arnoldknott May 31, 2026
50662b1
chore: applies createPending to relationHandler
arnoldknott Jun 1, 2026
b7afef8
chore: applies createPending to one caller - identity to create ueber…
arnoldknott Jun 1, 2026
7b10010
chore: formats status as dict instead of one-item tuple
arnoldknott Jun 2, 2026
07f3c1e
chore: emits status=linked if resource for submit:create has a paren…
arnoldknott Jun 2, 2026
be67c85
test: adopts a test to accomodate for the status:linked on submit:create
arnoldknott Jun 2, 2026
bb81bf4
chore: changes the datestyle to remove the seconds on DemoResource co…
arnoldknott Jun 3, 2026
87e4178
chore: changes the datestyle to remove the seconds on DemoResource card
arnoldknott Jun 3, 2026
2f59c50
chore: updates socketIO class and relationHandler to keep track of en…
arnoldknott Jun 3, 2026
f0b0b3a
refactor: instantiations of socketio and relationHandler to use the u…
arnoldknott Jun 3, 2026
eecb368
test: updates tests for socketio and relationhandler to adress the up…
arnoldknott Jun 3, 2026
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
8 changes: 4 additions & 4 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ FROM base AS api_dev
RUN apk add --no-cache docker-cli git
RUN uv sync --frozen --extra dev
# TBD: switch to log-level warning, when app is more mature:
# CMD ["uvicorn", "main:app", "--reload", "--log-level", "warning" ,"--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "main:fastapi_app", "--reload", "--no-access-log", "--host", "0.0.0.0", "--port", "80"]
# CMD ["uvicorn", "main:app", "--reload", "--log-level", "warning", "--ws", "websockets-sansio" ,"--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "main:fastapi_app", "--reload", "--no-access-log", "--ws", "websockets-sansio", "--host", "0.0.0.0", "--port", "80"]

FROM base AS api_prod
LABEL org.opencontainers.image.source https://github.com/arnoldknott/fullstacksandbox23
Expand All @@ -33,8 +33,8 @@ RUN uv sync --frozen --no-dev --no-cache
ARG COMMIT_SHA="noGitBuild"
ENV COMMIT_SHA=$COMMIT_SHA
# TBD: switch to log-level warning, when app is more mature:
# CMD ["uvicorn", "main:app", "--log-level", "warning", "--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "main:fastapi_app", "--no-access-log", "--host", "0.0.0.0", "--port", "80"]
# CMD ["uvicorn", "main:app", "--log-level", "warning", "--ws", "websockets-sansio", "--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "main:fastapi_app", "--no-access-log", "--ws", "websockets-sansio", "--host", "0.0.0.0", "--port", "80"]


FROM base AS worker_dev
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ dependencies = [
'fastapi[standard]>=0.116.1,<1',
'pyjwt[crypto]>=2.10.1,<3',
'requests>=2.32.3,<3',
'uvicorn[standard]>=0.34.0,<1',
'uvicorn[standard]>=0.48.0,<1',
'azure-identity>=1.19.0,<2',
'azure-keyvault-secrets>=4.9.0,<5',
'pydantic-settings>=2.10.1,<3',
Expand Down
162 changes: 73 additions & 89 deletions backend/src/crud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,6 @@ async def create( # noqa: C901
# this should be doable in the same database call as the access policy and the access log creation.
# self._add_identifier_type_link_to_session(database_object.id)

# TBD: create the statements in the methods, but execute together - less round-trips to database
# await self._write_identifier_type_link(database_object.id)
# await self._write_policy(database_object.id, own, current_user)
# That's what session handling is for - it depends on where to exec() the session.

# Create owner access policy
# if not is_public_creation:
access_policy = AccessPolicyCreate(
Expand Down Expand Up @@ -446,31 +441,6 @@ async def create_file(
detail=f"{self.model.__name__} - Forbidden.",
)

# async def create_public(
# self,
# object: BaseSchemaTypeCreate,
# current_user: "CurrentUserData",
# parent_id: Optional[uuid.UUID] = None,
# inherit: Optional[bool] = False,
# action: Action = read,
# ) -> BaseModelType:
# """Creates a new object with public access."""
# database_object = await self.create(object, current_user, parent_id, inherit)

# public_access_policy = AccessPolicyCreate(
# resource_id=database_object.id,
# action=action,
# public=True,
# )
# async with self.policy_CRUD as policy_CRUD:
# await policy_CRUD.create(
# public_access_policy,
# current_user,
# allow_override=self.allow_standalone,
# )

# return database_object

async def add_child_to_parent(
self,
child_id: uuid.UUID,
Expand Down Expand Up @@ -560,69 +530,83 @@ async def read( # noqa: C901
related_model = self.type.get_model(relationship.mapper.class_.__name__)
related_attribute = getattr(self.model, relationship.key)
related_type = self.type(related_model.__name__)
related_statement = select(related_model.id)
# related_statement = self.policy_CRUD.filters_allowed(
related_statement = self.policy_crud.filters_allowed(
related_statement,
action=read,
model=related_model,
current_user=current_user,

# Skip relationships that are not part of the configured hierarchy
# (e.g. direct-FK side tables like User.user_profile / User.user_account).
# Their access is governed by access to the parent model, and adding a
# WHERE on `related_model.id` here without a corresponding join causes
# cartesian-product SAWarnings; let their declared `lazy=` strategy load them.
is_hierarchy_relationship = any(
(self.entity_type == parent and related_type in children)
or (self.entity_type in children and related_type == parent)
for parent, children in self.relations.items()
)
if is_hierarchy_relationship:
related_statement = select(related_model.id)
# related_statement = self.policy_CRUD.filters_allowed(
related_statement = self.policy_crud.filters_allowed(
related_statement,
action=read,
model=related_model,
current_user=current_user,
)

# Check if self.entity_type is a key in relations, i.e. the model is a parent in the hierarchy
aliased_hierarchy = aliased(self.hierarchy)
for parent, children in self.relations.items():
if self.entity_type == parent and related_type in children:
# self.model is a parent, join on parent_id
statement = statement.outerjoin(
aliased_hierarchy,
col(self.model.id)
== foreign(col(aliased_hierarchy.parent_id)),
)
statement = statement.outerjoin(
related_model,
col(related_model.id)
== foreign(col(aliased_hierarchy.child_id)),
)
if self.hierarchy is ResourceHierarchy:
# `aliased_hierarchy` was built from `self.hierarchy`, so in
# this branch its underlying class is ResourceHierarchy and
# therefore has an `order` column. Pyright cannot follow this
# correlation across the `aliased(...)` call.
statement = statement.order_by(asc(col(aliased_hierarchy.order))) # type: ignore[attr-defined]
else:
# Check if self.entity_type is a key in relations, i.e. the model is a parent in the hierarchy
aliased_hierarchy = aliased(self.hierarchy)
for parent, children in self.relations.items():
if self.entity_type == parent and related_type in children:
# self.model is a parent, join on parent_id
statement = statement.outerjoin(
aliased_hierarchy,
col(self.model.id)
== foreign(col(aliased_hierarchy.parent_id)),
)
statement = statement.outerjoin(
related_model,
col(related_model.id)
== foreign(col(aliased_hierarchy.child_id)),
)
if self.hierarchy is ResourceHierarchy:
# `aliased_hierarchy` was built from `self.hierarchy`, so in
# this branch its underlying class is ResourceHierarchy and
# therefore has an `order` column. Pyright cannot follow this
# correlation across the `aliased(...)` call.
statement = statement.order_by(asc(col(aliased_hierarchy.order))) # type: ignore[attr-defined]
else:
statement = statement.order_by(
asc(col(related_model.id))
)
elif self.entity_type in children and related_type == parent:
# self.model is a child, join on child_id
statement = statement.outerjoin(
aliased_hierarchy,
col(self.model.id)
== foreign(col(aliased_hierarchy.child_id)),
)
statement = statement.outerjoin(
related_model,
col(related_model.id)
== foreign(col(aliased_hierarchy.parent_id)),
)
# here no ordering, as parents don't have an order seen from the child:
statement = statement.order_by(asc(col(related_model.id)))
elif self.entity_type in children and related_type == parent:
# self.model is a child, join on child_id
statement = statement.outerjoin(
aliased_hierarchy,
col(self.model.id)
== foreign(col(aliased_hierarchy.child_id)),
)
statement = statement.outerjoin(
related_model,
col(related_model.id)
== foreign(col(aliased_hierarchy.parent_id)),
)
# here no ordering, as parents don't have an order seen from the child:
statement = statement.order_by(asc(col(related_model.id)))

count_related_statement = select(func.count()).select_from(
related_statement.alias()
)
related_count = await self.session.exec(count_related_statement)
count = related_count.one()

if count == 0:
statement = statement.options(noload(related_attribute))
else:
statement = statement.where(
or_(
related_model.id
== None, # noqa E711: comparison to None should be 'if cond is None:'
related_model.id.in_(related_statement),
)
).options(contains_eager(related_attribute))
count_related_statement = select(func.count()).select_from(
related_statement.alias()
)
related_count = await self.session.exec(count_related_statement)
count = related_count.one()

if count == 0:
statement = statement.options(noload(related_attribute))
else:
statement = statement.where(
or_(
related_model.id
== None, # noqa E711: comparison to None should be 'if cond is None:'
related_model.id.in_(related_statement),
)
).options(contains_eager(related_attribute))

if joins:
for join in joins:
Expand Down
37 changes: 4 additions & 33 deletions backend/src/crud/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,41 +384,12 @@ async def read_me(self, current_user: CurrentUserData) -> Me:
"""Returns the current user."""
try:

# This is for checking the access rights of the user to itself:
# Version 1:
# TBD fix cartesian product in the query when admin calls this!
# problem started since the user_account was added to the user!
# Challenge is in the model, not in the query!
# This is for checking the access rights of the user to itself.
# Returns model Me, which includes user_profile and user_account.
# Always filters by current_user.user_id, so even Admin only ever gets
# their own profile/account through this method.
user = await self.read_by_id(current_user.user_id, current_user)

# Version 2: check access first and then read directly from the database:
# access_request = AccessRequest(
# current_user=current_user,
# resource_id=current_user.user_id,
# action=Action.own,
# )
# await self.policy_CRUD.allows(access_request)
# user_query = (
# select(User).where(User.id == current_user.user_id)
# # .join(UserAccount, UserAccount.user_id == User.id)
# # .options(selectinload(User.user_account))
# )
# user_response = await self.session.exec(user_query)
# user = user_response.unique().one()

# me = Me.model_validate(user)
# print("=== user crud - read_me - me ===")
# print(me)
query = select(UserAccount, UserProfile).where(
UserAccount.user_id == current_user.user_id,
UserProfile.user_id == current_user.user_id,
)
response = await self.session.exec(query)
account, profile = response.one()
user.user_account = account
user.user_profile = profile

# Add detailed logging before model_validate
me = Me.model_validate(user)
me.azure_token_roles = current_user.azure_token_roles
me.azure_token_groups = current_user.azure_token_groups
Expand Down
2 changes: 2 additions & 0 deletions backend/src/models/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ class UserRead(UserCreate):


# This is the model, a users can see about themselves.
# Only use of this model is in the /user/me endpoint, which ensures,
# that only the user can see their own user_account and user_profile, but not other users.
class Me(UserRead):
azure_token_roles: Optional[list[str]] = None
azure_token_groups: Optional[list[uuid.UUID]] = None
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routers/api/v1/tests/test_identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,8 +786,8 @@ async def test_user_gets_user_by_azure_user_id(
assert modelled_response_user.azure_user_id == user_in_database.azure_user_id
assert modelled_response_user.azure_tenant_id == user_in_database.azure_tenant_id
assert len(modelled_response_user.azure_groups) == 3 # type: ignore[arg-type]
assert not hasattr(modelled_response_user, "user_account")
assert not hasattr(modelled_response_user, "user_profile")
assert "user_account" not in response_user
assert "user_profile" not in response_user

async with AccessLoggingCRUD() as crud:
created_at = await crud.read_resource_created_at(
Expand Down
53 changes: 44 additions & 9 deletions backend/src/routers/socketio/v1/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,8 @@ async def on_read(self, sid, resource_id: Optional[UUID] = None):
# await self._emit_status(sid, {"error": str(error)})

# "submit" is communication from client to server
async def on_submit(self, sid, data):
# TBD: remove noqa, when emiting the link status events is gathered in a separate method.
async def on_submit(self, sid, data): # noqa: C901
"""Gets data from client and issues a create or update based on id is present or not."""
logger.info(f"🧦 Data submitted from client {sid}")
try:
Expand Down Expand Up @@ -684,6 +685,14 @@ async def on_submit(self, sid, data):
public,
public_action,
)
parent_type = None
if parent_id is not None:
parent_types = await crud._get_types_from_ids(
[parent_id]
)
parent_type = (
parent_types[0].type if parent_types else None
)
await self.server.enter_room(
sid,
f"resource:{str(database_object.id)}",
Expand All @@ -701,7 +710,35 @@ async def on_submit(self, sid, data):
# so they get notified through a "shared" event.
rooms = ["role:Admin"]
if parent_id is not None:
# This one is for emiting in child-namespace, room "parend_id",
# so all clients that are in that room get the update about the new child resource
# and can decide what to do with it based on the parent_id information.
# Is it actually necessary to emit this in the child-namespace?
# Probably yes, becasue some children might not be connected to the parent_namespace,
# but still want to list all their parents.
rooms += [f"parent:{parent_id}"]
# emit same status as in "on_link" - duplicate here
# TBD: consider refactoring into a separate method,
# to avoid that dublication.
status = {
"success": "linked",
"id": str(database_object.id),
"parent_id": str(parent_id),
"inherit": inherit,
}
# Currently one of those emits is tested in
# test_connect_create_read_update_delete_sub_group
# TBD: add another test for the other emit
await self._emit_status(
sid, status, [f"resource:{str(database_object.id)}"]
)
parent_namespace = registry_namespaces.get(parent_type)
await self._emit_status(
sid,
status,
[f"resource:{str(parent_id)}"],
namespace=parent_namespace,
)
await self.server.emit(
"status",
{
Expand Down Expand Up @@ -881,14 +918,12 @@ async def on_link(self, sid, hierarchy: Dict[str, Any]):
)
parent_types = await crud._get_types_from_ids([hierarchy_obj.parent_id])
parent_type = parent_types[0].type if parent_types else None
status = (
{
"success": "linked",
"id": str(hierarchy_obj.child_id),
"parent_id": str(hierarchy_obj.parent_id),
"inherit": hierarchy_obj.inherit,
},
)
status = {
"success": "linked",
"id": str(hierarchy_obj.child_id),
"parent_id": str(hierarchy_obj.parent_id),
"inherit": hierarchy_obj.inherit,
}
await self._emit_status(
sid, status, [f"resource:{str(hierarchy_obj.child_id)}"]
)
Expand Down
Loading