Skip to content

Commit 78d60b0

Browse files
committed
Introduce project provisioning on user registration
1 parent 6864815 commit 78d60b0

8 files changed

Lines changed: 250 additions & 0 deletions

File tree

exordos/manifests/ecosystem_realm.yaml.j2

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,12 @@ resources:
6363
setter:
6464
kind: selector
6565
selector_strategy: latest
66+
67+
# Enable auto-provisioning of personal workspace (org + project) on email confirmation
68+
registration_auto_provision:
69+
uuid: "9eeb513b-9314-40a0-b599-32295a967400"
70+
name: "registration_auto_provision"
71+
project_id: "12345678-c625-4fee-81d5-f691897b8142"
72+
setter:
73+
kind: selector
74+
selector_strategy: latest

exordos_core/bootstrap/defaults.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,31 @@ def set_disable_telemetry_var(spec: dict[str, tp.Any]) -> bool:
476476
return True
477477

478478

479+
def set_registration_auto_provision_var(spec: dict[str, tp.Any]) -> bool:
480+
val_uuid = sys_uuid.UUID("5e0b40b7-9a12-41e4-9d98-0d39e60a76b2")
481+
existing_value = vs_models.Value.objects.get_one_or_none(
482+
filters={"uuid": dm_filters.EQ(val_uuid)}
483+
)
484+
if existing_value:
485+
LOG.info("Registration auto provision variable already exists")
486+
return True
487+
488+
LOG.info("Set registration_auto_provision variable")
489+
reg_auto_provision_var = vs_models.Variable.objects.get_one_or_none(
490+
filters={"uuid": dm_filters.EQ(c.VAR_REGISTRATION_AUTO_PROVISION_UUID)}
491+
)
492+
if not reg_auto_provision_var:
493+
return False
494+
495+
vs_models.Value(
496+
uuid=val_uuid,
497+
variable=reg_auto_provision_var,
498+
value=spec.get("registration_auto_provision", False),
499+
project_id=c.EM_HIDDEN_PROJECT_ID,
500+
).insert()
501+
return True
502+
503+
479504
def set_realm_uuid_var(spec: dict[str, tp.Any]) -> bool:
480505
val_uuid = sys_uuid.UUID("f134c97f-bb4d-4d67-9527-8c90e2f04f8c")
481506
existing_value = vs_models.Value.objects.get_one_or_none(

exordos_core/cmd/bootstrap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ def _set_defaults_vs(spec: dict[str, tp.Any]):
323323
{"func": bootstrap_defaults.set_core_data_disk_size_var, "args": [spec]},
324324
{"func": bootstrap_defaults.set_ecosystem_endpoint_var, "args": [spec]},
325325
{"func": bootstrap_defaults.set_disable_telemetry_var, "args": [spec]},
326+
{
327+
"func": bootstrap_defaults.set_registration_auto_provision_var,
328+
"args": [spec],
329+
},
326330
{"func": bootstrap_defaults.set_realm_uuid_var, "args": [spec]},
327331
{"func": bootstrap_defaults.set_realm_secret_var, "args": [spec]},
328332
{"func": bootstrap_defaults.set_realm_access_token_var, "args": [spec]},

exordos_core/common/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
VAR_HS256_JWKS_ENCRYPTION_KEY_UUID = sys_uuid.UUID(
9292
"c371a647-e1a6-4bec-bef2-a50041bc5af2"
9393
)
94+
VAR_REGISTRATION_AUTO_PROVISION_UUID = sys_uuid.UUID(
95+
"9eeb513b-9314-40a0-b599-32295a967400"
96+
)
9497

9598
REPOSITORY_URL = "https://repo.exordos.com"
9699
ELEMENTS_PATH = "exordos-elements"

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

Lines changed: 54 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+
from unittest import mock
1819

1920
from bazooka import exceptions as bazooka_exc
2021
from gcl_iam.tests.functional import clients as iam_clients
@@ -416,6 +417,59 @@ def test_confirm_email_expired_code_400_error(
416417
code=str(user.confirmation_code),
417418
)
418419

420+
def test_confirm_email_provisions_workspace_when_enabled(
421+
self,
422+
user_api_noauth_client,
423+
auth_test1_user,
424+
):
425+
user = iam_models.User.objects.get_one(filters={"uuid": auth_test1_user.uuid})
426+
user.email_verified = False
427+
user.create_confirmation_code()
428+
user.save()
429+
430+
with mock.patch(
431+
"exordos_core.user_api.iam.api.controllers.UserController"
432+
"._is_auto_provision_enabled",
433+
return_value=True,
434+
):
435+
client = user_api_noauth_client()
436+
client.confirm_email(
437+
user_uuid=auth_test1_user.uuid,
438+
code=str(user.confirmation_code),
439+
)
440+
441+
org = iam_models.Organization.get_default(user=user)
442+
assert org is not None
443+
assert org.name == user.name
444+
445+
project = iam_models.Project.get_default(user=user, organization=org)
446+
assert project is not None
447+
assert project.name == "default"
448+
449+
def test_confirm_email_no_provisioning_by_default(
450+
self,
451+
user_api_noauth_client,
452+
auth_test1_user,
453+
):
454+
user = iam_models.User.objects.get_one(filters={"uuid": auth_test1_user.uuid})
455+
user.email_verified = False
456+
user.create_confirmation_code()
457+
user.save()
458+
459+
with mock.patch(
460+
"exordos_core.user_api.iam.api.controllers.UserController"
461+
"._is_auto_provision_enabled",
462+
return_value=False,
463+
):
464+
client = user_api_noauth_client()
465+
client.confirm_email(
466+
user_uuid=auth_test1_user.uuid,
467+
code=str(user.confirmation_code),
468+
)
469+
470+
org = iam_models.Organization.get_default(user=user)
471+
assert org is None
472+
419473
def test_delete_my_user_test1_auth_success(self, user_api_client, auth_test1_user):
420474
client = user_api_client(
421475
auth_test1_user,
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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@
4040
from restalchemy.dm import filters as ra_filters
4141
from restalchemy.openapi import utils as oa_utils
4242

43+
from exordos_core.common import constants as common_c
4344
from exordos_core.user_api.iam import constants as c
4445
from exordos_core.user_api.iam import exceptions as iam_e
4546
from exordos_core.user_api.iam.api import openapi_specs as oa_specs
4647
from exordos_core.user_api.iam.clients import idp
4748
from exordos_core.user_api.iam.dm import models
49+
from exordos_core.vs.dm import models as vs_models
4850

4951
LOG = logging.getLogger(__name__)
5052

@@ -294,19 +296,46 @@ def resend_email_confirmation(self, resource):
294296
# Don't leak user data
295297
return None
296298

299+
def _is_auto_provision_enabled(self) -> bool:
300+
var = vs_models.Variable.objects.get_one_or_none(
301+
filters={
302+
"uuid": ra_filters.EQ(common_c.VAR_REGISTRATION_AUTO_PROVISION_UUID)
303+
}
304+
)
305+
if not var or not var.value:
306+
return False
307+
return str(var.value).lower() in ("true", "1", "yes")
308+
309+
def _maybe_provision_workspace(self, user: models.User) -> None:
310+
if not self._is_auto_provision_enabled():
311+
return
312+
313+
# Lock the user row for the rest of the transaction so concurrent
314+
# confirmation requests can't race past the get_default() check
315+
# below and each provision a separate personal workspace.
316+
locked_user = models.User.objects.get_one(
317+
filters={"uuid": ra_filters.EQ(user.uuid)},
318+
locked=True,
319+
)
320+
if models.Organization.get_default(user=locked_user) is not None:
321+
return
322+
locked_user.provision_personal_workspace()
323+
297324
@actions.post
298325
def force_confirm_email(self, resource):
299326
rule = c.PERMISSION_USER_WRITE_ALL
300327
if not self.enforce(rule):
301328
raise iam_e.CanNotUpdateUser(uuid=resource.uuid, rule=rule)
302329

303330
resource.confirm_email()
331+
self._maybe_provision_workspace(resource)
304332
return None
305333

306334
@actions.post
307335
def confirm_email(self, resource, code=None):
308336
code = code or self._req.params.get("code", "")
309337
resource.confirm_email_by_code(code)
338+
self._maybe_provision_workspace(resource)
310339
return resource
311340

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

exordos_core/user_api/iam/dm/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,22 @@ def make_newcomer(self):
439439
role_binding.save()
440440
return role_binding
441441

442+
def provision_personal_workspace(self):
443+
org = Organization(name=self.name, description="Personal workspace")
444+
org.insert()
445+
OrganizationMember(
446+
organization=org,
447+
user=self,
448+
role=iam_c.OrganizationRole.OWNER.value,
449+
).insert()
450+
project = Project(
451+
name="default",
452+
description="Default project",
453+
organization=org,
454+
)
455+
project.insert()
456+
project.add_owner(self)
457+
442458
def get_my_roles(self):
443459
return RolesInfo(
444460
[

0 commit comments

Comments
 (0)