Skip to content

Commit 61b6760

Browse files
committed
Introduce project provisioning on user registration
A personal workspace (organization + default project) is provisioned when a user confirms their email, controlled per IAM client: - IamClient gets a registration_auto_provision flag (enabled by default for new clients). - The client the user registered through is recorded in users.registration_client. Anonymous (street) registrations are attributed to the default client. - On (force_)confirm_email the workspace is provisioned if the recorded client has the flag enabled. Existing users have no registration client recorded and are not affected. Migration 0003 downgrade is made tolerant to organizations that lost their owner member (e.g. the user was deleted): fall back to any member, then to the oldest user, instead of failing on SET NOT NULL.
1 parent 1277448 commit 61b6760

7 files changed

Lines changed: 391 additions & 0 deletions

File tree

exordos_core/tests/functional/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ def user_api(user_api_service: test_utils.RestServiceTestCase):
191191
# TODO(slashburygin): setup_method(apply_migrations) should be called once, not every test. Should be fixed after squash migrations
192192
user_api_service.setup_method()
193193

194+
# Keep the legacy baseline for tests: no implicit personal workspace
195+
# provisioning on email confirmation. Feature tests enable the flag
196+
# explicitly.
197+
default_client = iam_models.IamClient.objects.get_one(
198+
filters={"uuid": dm_filters.EQ(sys_uuid.UUID(c.DEFAULT_CLIENT_UUID))}
199+
)
200+
default_client.registration_auto_provision = False
201+
default_client.save()
202+
194203
yield user_api_service
195204

196205
# user_api_service.teardown_method()

exordos_core/tests/functional/restapi/iam/test_users.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# under the License.
1616
import contextlib
1717
import datetime
18+
import uuid as sys_uuid
1819

1920
from bazooka import exceptions as bazooka_exc
2021
from gcl_iam.tests.functional import clients as iam_clients
@@ -425,6 +426,109 @@ def test_confirm_email_expired_code_400_error(
425426
code=str(user.confirmation_code),
426427
)
427428

429+
def _create_unconfirmed_user(self, user_api_client, auth_user_admin, username):
430+
client = user_api_client(
431+
auth_user_admin,
432+
permissions=[
433+
iam_c.PERMISSION_USER_CREATE,
434+
],
435+
)
436+
created = client.create_user(username=username, password="testtest")
437+
user = iam_models.User.objects.get_one(filters={"uuid": created["uuid"]})
438+
user.create_confirmation_code()
439+
user.save()
440+
return user
441+
442+
def test_create_user_records_registration_client(
443+
self, user_api_client, auth_user_admin
444+
):
445+
client = user_api_client(
446+
auth_user_admin,
447+
permissions=[
448+
iam_c.PERMISSION_USER_CREATE,
449+
],
450+
)
451+
452+
user = client.create_user(username="reg_client_user", password="testtest")
453+
454+
user_obj = iam_models.User.objects.get_one(filters={"uuid": user["uuid"]})
455+
assert user_obj.registration_client == sys_uuid.UUID(
456+
common_c.DEFAULT_CLIENT_UUID
457+
)
458+
459+
def _set_default_client_auto_provision(self, enabled):
460+
iam_client = iam_models.IamClient.objects.get_one(
461+
filters={"uuid": sys_uuid.UUID(common_c.DEFAULT_CLIENT_UUID)}
462+
)
463+
iam_client.registration_auto_provision = enabled
464+
iam_client.save()
465+
466+
def test_confirm_email_provisions_workspace_when_enabled_on_client(
467+
self,
468+
user_api_client,
469+
user_api_noauth_client,
470+
auth_user_admin,
471+
):
472+
self._set_default_client_auto_provision(True)
473+
user = self._create_unconfirmed_user(
474+
user_api_client, auth_user_admin, "provision_user"
475+
)
476+
477+
client = user_api_noauth_client()
478+
client.confirm_email(
479+
user_uuid=user.uuid,
480+
code=str(user.confirmation_code),
481+
)
482+
483+
org = iam_models.Organization.get_default(user=user)
484+
assert org is not None
485+
assert org.name == user.name
486+
487+
project = iam_models.Project.get_default(user=user, organization=org)
488+
assert project is not None
489+
assert project.name == "default"
490+
491+
def test_confirm_email_no_provisioning_without_registration_client(
492+
self,
493+
user_api_client,
494+
user_api_noauth_client,
495+
auth_user_admin,
496+
):
497+
self._set_default_client_auto_provision(True)
498+
user = self._create_unconfirmed_user(
499+
user_api_client, auth_user_admin, "no_reg_client_user"
500+
)
501+
user.registration_client = None
502+
user.save()
503+
504+
client = user_api_noauth_client()
505+
client.confirm_email(
506+
user_uuid=user.uuid,
507+
code=str(user.confirmation_code),
508+
)
509+
510+
org = iam_models.Organization.get_default(user=user)
511+
assert org is None
512+
513+
def test_confirm_email_no_provisioning_when_disabled_on_client(
514+
self,
515+
user_api_client,
516+
user_api_noauth_client,
517+
auth_user_admin,
518+
):
519+
user = self._create_unconfirmed_user(
520+
user_api_client, auth_user_admin, "disabled_client_user"
521+
)
522+
523+
client = user_api_noauth_client()
524+
client.confirm_email(
525+
user_uuid=user.uuid,
526+
code=str(user.confirmation_code),
527+
)
528+
529+
org = iam_models.Organization.get_default(user=user)
530+
assert org is None
531+
428532
def test_delete_my_user_test1_auth_success(self, user_api_client, auth_test1_user):
429533
client = user_api_client(
430534
auth_test1_user,
@@ -485,6 +589,7 @@ def test_fields_in_me_info_success(
485589
"email",
486590
"otp_enabled",
487591
"email_verified",
592+
"registration_client",
488593
"type",
489594
]
490595

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2025-2026 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
from unittest import mock
18+
import uuid
19+
20+
import pytest
21+
22+
from exordos_core.user_api.iam import constants as iam_c
23+
from exordos_core.user_api.iam.dm import models
24+
25+
26+
class TestProvisionPersonalWorkspace:
27+
@pytest.fixture
28+
def user(self):
29+
u = models.User.__new__(models.User)
30+
u.name = "testuser"
31+
u.uuid = uuid.uuid4()
32+
return u
33+
34+
@pytest.fixture
35+
def org(self):
36+
o = mock.MagicMock()
37+
o.uuid = uuid.uuid4()
38+
return o
39+
40+
@pytest.fixture
41+
def project(self):
42+
p = mock.MagicMock()
43+
p.uuid = uuid.uuid4()
44+
return p
45+
46+
def test_creates_org_with_username_as_name(self, user, org, project):
47+
with (
48+
mock.patch.object(models, "Organization") as MockOrg,
49+
mock.patch.object(models, "OrganizationMember"),
50+
mock.patch.object(models, "Project") as MockProject,
51+
):
52+
MockOrg.return_value = org
53+
MockProject.return_value = project
54+
55+
user.provision_personal_workspace()
56+
57+
MockOrg.assert_called_once_with(
58+
name=user.name,
59+
description="Personal workspace",
60+
)
61+
org.insert.assert_called_once()
62+
63+
def test_assigns_user_as_org_owner(self, user, org, project):
64+
with (
65+
mock.patch.object(models, "Organization") as MockOrg,
66+
mock.patch.object(models, "OrganizationMember") as MockMember,
67+
mock.patch.object(models, "Project") as MockProject,
68+
):
69+
MockOrg.return_value = org
70+
MockProject.return_value = project
71+
72+
user.provision_personal_workspace()
73+
74+
MockMember.assert_called_once_with(
75+
organization=org,
76+
user=user,
77+
role=iam_c.OrganizationRole.OWNER.value,
78+
)
79+
MockMember.return_value.insert.assert_called_once()
80+
81+
def test_creates_default_project_in_org(self, user, org, project):
82+
with (
83+
mock.patch.object(models, "Organization") as MockOrg,
84+
mock.patch.object(models, "OrganizationMember"),
85+
mock.patch.object(models, "Project") as MockProject,
86+
):
87+
MockOrg.return_value = org
88+
MockProject.return_value = project
89+
90+
user.provision_personal_workspace()
91+
92+
MockProject.assert_called_once_with(
93+
name="default",
94+
description="Default project",
95+
organization=org,
96+
)
97+
project.insert.assert_called_once()
98+
99+
def test_calls_add_owner_with_self(self, user, org, project):
100+
with (
101+
mock.patch.object(models, "Organization") as MockOrg,
102+
mock.patch.object(models, "OrganizationMember"),
103+
mock.patch.object(models, "Project") as MockProject,
104+
):
105+
MockOrg.return_value = org
106+
MockProject.return_value = project
107+
108+
user.provision_personal_workspace()
109+
110+
project.add_owner.assert_called_once_with(user)

exordos_core/user_api/iam/api/controllers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import errno
1818
import logging
1919
import mimetypes
20+
import uuid as sys_uuid
2021
from os import path as os_path
2122
import re
2223
import string
@@ -27,6 +28,7 @@
2728
from gcl_iam import rules
2829
from gcl_iam.api import controllers as iam_controllers
2930
from gcl_iam.api import field_perms as iam_fp
31+
from gcl_iam import exceptions as gcl_iam_e
3032
import jinja2
3133
import pyotp
3234
from restalchemy.api import actions
@@ -40,6 +42,7 @@
4042
from restalchemy.dm import filters as ra_filters
4143
from restalchemy.openapi import utils as oa_utils
4244

45+
from exordos_core.common import constants as common_c
4346
from exordos_core.user_api.iam import constants as c
4447
from exordos_core.user_api.iam import exceptions as iam_e
4548
from exordos_core.user_api.iam.api import openapi_specs as oa_specs
@@ -190,6 +193,10 @@ def create(self, **kwargs):
190193
)
191194
self.validate_secret(kwargs)
192195
kwargs.pop("email_verified", None)
196+
kwargs.pop("registration_client", None)
197+
iam_client = self._get_request_iam_client()
198+
if iam_client is not None:
199+
kwargs["registration_client"] = iam_client.uuid
193200
user = super().create(**kwargs)
194201
app_endpoint = _get_app_endpoint(req=self._req)
195202
user.resend_confirmation_event(app_endpoint=app_endpoint)
@@ -211,6 +218,7 @@ def filter(self, filters, **kwargs):
211218
def update(self, uuid, **kwargs):
212219
self.validate_secret(kwargs)
213220
kwargs.pop("email_verified", None)
221+
kwargs.pop("registration_client", None)
214222
is_me = models.User.me().uuid == uuid
215223
if is_me or self.enforce(c.PERMISSION_USER_WRITE_ALL):
216224
return super().update(uuid, **kwargs)
@@ -294,19 +302,58 @@ def resend_email_confirmation(self, resource):
294302
# Don't leak user data
295303
return None
296304

305+
def _get_request_iam_client(self) -> models.IamClient | None:
306+
try:
307+
return models.Token.my().iam_client
308+
except gcl_iam_e.InvalidAuthTokenError:
309+
# Anonymous registration (e.g. street sign-up) is not bound to
310+
# a client token; attribute it to the default client.
311+
return models.IamClient.objects.get_one_or_none(
312+
filters={
313+
"uuid": ra_filters.EQ(
314+
sys_uuid.UUID(common_c.DEFAULT_CLIENT_UUID)
315+
)
316+
}
317+
)
318+
319+
def _is_auto_provision_enabled(self, user: models.User) -> bool:
320+
if user.registration_client is None:
321+
return False
322+
client = models.IamClient.objects.get_one_or_none(
323+
filters={"uuid": ra_filters.EQ(user.registration_client)}
324+
)
325+
return client is not None and client.registration_auto_provision
326+
327+
def _maybe_provision_workspace(self, user: models.User) -> None:
328+
if not self._is_auto_provision_enabled(user):
329+
return
330+
331+
# Lock the user row for the rest of the transaction so concurrent
332+
# confirmation requests can't race past the get_default() check
333+
# below and each provision a separate personal workspace.
334+
locked_user = models.User.objects.get_one(
335+
filters={"uuid": ra_filters.EQ(user.uuid)},
336+
locked=True,
337+
)
338+
if models.Organization.get_default(user=locked_user) is not None:
339+
return
340+
locked_user.provision_personal_workspace()
341+
297342
@actions.post
298343
def force_confirm_email(self, resource):
299344
rule = c.PERMISSION_USER_WRITE_ALL
300345
if not self.enforce(rule):
301346
raise iam_e.CanNotUpdateUser(uuid=resource.uuid, rule=rule)
302347

303348
resource.confirm_email()
349+
self._maybe_provision_workspace(resource)
304350
return None
305351

306352
@actions.post
307353
def confirm_email(self, resource, code=None):
308354
code = code or self._req.params.get("code", "")
309355
resource.confirm_email_by_code(code)
356+
self._maybe_provision_workspace(resource)
310357
return resource
311358

312359
@oa_utils.extend_schema(**oa_specs.OA_SPEC_RESET_PASSWORD_USER)

0 commit comments

Comments
 (0)