Skip to content

Commit e5e6d2e

Browse files
committed
Secret manager
Signed-off-by: Anton Kremenetsky <anton.kremenetsky@gmail.com>
1 parent f36e037 commit e5e6d2e

13 files changed

Lines changed: 519 additions & 4 deletions

File tree

genesis_core/config/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
DEF_OUTDATE_MIN_PERIOD = datetime.timedelta(minutes=10)
4040

4141

42-
class ConfigService(basic.BasicService):
42+
class ConfigServiceBuilder(basic.BasicService):
4343

4444
def _get_new_configs(
4545
self,

genesis_core/gservice/service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from genesis_core.node.machine import service as n_machine_service
2727
from genesis_core.network import service as n_network_service
2828
from genesis_core.config import service as config_service
29+
from genesis_core.secret import service as secret_service
2930

3031

3132
LOG = logging.getLogger(__name__)
@@ -70,7 +71,8 @@ def __init__(self, iter_min_period=1, iter_pause=0.1):
7071
n_machine = n_machine_service.MachineAgentService(
7172
iter_min_period=1, iter_pause=0.1
7273
)
73-
cfg_service = config_service.ConfigService()
74+
cfg_service = config_service.ConfigServiceBuilder()
75+
secret_svc = secret_service.SecretServiceBuilder()
7476
event_sender = senders.EventSenderService.build_from_config()
7577

7678
self._services = [
@@ -79,6 +81,7 @@ def __init__(self, iter_min_period=1, iter_pause=0.1):
7981
n_builder,
8082
n_machine,
8183
cfg_service,
84+
secret_svc,
8285
event_sender,
8386
]
8487

genesis_core/secret/__init__.py

Whitespace-only changes.

genesis_core/secret/constants.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright 2025 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+
import enum
18+
19+
DEFAULT_SQL_LIMIT = 100
20+
PASSWORD_KIND = "password"
21+
22+
23+
class SecretStatus(str, enum.Enum):
24+
NEW = "NEW"
25+
IN_PROGRESS = "IN_PROGRESS"
26+
ACTIVE = "ACTIVE"
27+
ERROR = "ERROR"
28+
29+
30+
class SecretKind(str, enum.Enum):
31+
AUTO = "AUTO"
32+
MANUAL = "MANUAL"

genesis_core/secret/dm/__init__.py

Whitespace-only changes.

genesis_core/secret/dm/models.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright 2025 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+
from __future__ import annotations
17+
18+
from restalchemy.dm import properties
19+
from restalchemy.dm import types
20+
from restalchemy.dm import types_dynamic
21+
from restalchemy.storage.sql import orm
22+
23+
from gcl_sdk.agents.universal.dm import models as ua_models
24+
25+
from genesis_core.common.dm import models as cm
26+
from genesis_core.secret import constants as sc
27+
28+
29+
class PlainPasswordContainer(types_dynamic.AbstractKindModel):
30+
KIND = "plain"
31+
32+
value = properties.property(
33+
types.AllowNone(types.String(min_length=1, max_length=256)),
34+
default=None,
35+
)
36+
37+
38+
class Password(
39+
cm.ModelWithFullAsset,
40+
orm.SQLStorableMixin,
41+
ua_models.TargetResourceMixin,
42+
ua_models.TargetResourceSQLStorableMixin,
43+
):
44+
__tablename__ = "secret_passwords"
45+
46+
kind = properties.property(
47+
types.Enum([s.value for s in sc.SecretKind]),
48+
default=sc.SecretKind.AUTO.value,
49+
)
50+
status = properties.property(
51+
types.Enum([s.value for s in sc.SecretStatus]),
52+
default=sc.SecretStatus.NEW.value,
53+
)
54+
password = properties.property(
55+
types_dynamic.KindModelSelectorType(
56+
types_dynamic.KindModelType(PlainPasswordContainer),
57+
),
58+
required=True,
59+
default=PlainPasswordContainer,
60+
)
61+
62+
@classmethod
63+
def get_new_passwords(
64+
cls, limit: int = sc.DEFAULT_SQL_LIMIT
65+
) -> list["Password"]:
66+
return cls.get_new_entities(cls.__tablename__, sc.PASSWORD_KIND, limit)
67+
68+
@classmethod
69+
def get_updated_passwords(
70+
cls, limit: int = sc.DEFAULT_SQL_LIMIT
71+
) -> list["Password"]:
72+
return cls.get_updated_entities(
73+
cls.__tablename__, sc.PASSWORD_KIND, limit
74+
)
75+
76+
@classmethod
77+
def get_deleted_passwords(
78+
cls, limit: int = sc.DEFAULT_SQL_LIMIT
79+
) -> list[ua_models.TargetResource]:
80+
return cls.get_deleted_entities(
81+
cls.__tablename__, sc.PASSWORD_KIND, limit
82+
)

genesis_core/secret/service.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Copyright 2025 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+
from __future__ import annotations
17+
18+
import logging
19+
import collections
20+
import typing as tp
21+
import uuid as sys_uuid
22+
23+
from restalchemy.common import contexts
24+
from restalchemy.dm import filters as dm_filters
25+
from gcl_looper.services import basic
26+
from gcl_sdk.agents.universal.dm import models as ua_models
27+
28+
from genesis_core.secret.dm import models
29+
from genesis_core.secret import constants as sc
30+
31+
32+
LOG = logging.getLogger(__name__)
33+
34+
35+
class SecretServiceBuilder(basic.BasicService):
36+
37+
def _get_new_passwords(
38+
self,
39+
limit: int = sc.DEFAULT_SQL_LIMIT,
40+
) -> list[models.Password]:
41+
return models.Password.get_new_passwords(limit=limit)
42+
43+
def _get_changed_passwords(
44+
self,
45+
limit: int = sc.DEFAULT_SQL_LIMIT,
46+
) -> list[models.Password]:
47+
return models.Password.get_updated_passwords(limit=limit)
48+
49+
def _get_deleted_passwords(
50+
self,
51+
limit: int = sc.DEFAULT_SQL_LIMIT,
52+
) -> list[ua_models.TargetResource]:
53+
return models.Password.get_deleted_passwords(limit=limit)
54+
55+
def _get_outdated_resources(
56+
self,
57+
limit: int = sc.DEFAULT_SQL_LIMIT,
58+
) -> dict[
59+
sys_uuid.UUID,
60+
list[tuple[ua_models.TargetResource, ua_models.Resource]],
61+
]:
62+
outdated = ua_models.OutdatedResource.objects.get_all(
63+
filters={"kind": dm_filters.EQ(sc.PASSWORD_KIND)},
64+
limit=limit,
65+
)
66+
res_map = collections.defaultdict(list)
67+
for pair in outdated:
68+
# Updated resource aren't outdated
69+
if (
70+
pair.target_resource.updated_at
71+
> pair.actual_resource.updated_at
72+
):
73+
continue
74+
75+
res_map[pair.target_resource.uuid].append(
76+
(pair.target_resource, pair.actual_resource)
77+
)
78+
79+
return res_map
80+
81+
def _get_outdated_passwords(
82+
self, uuids: tp.Collection[sys_uuid.UUID]
83+
) -> list[models.Password]:
84+
return models.Password.objects.get_all(
85+
filters={"uuid": dm_filters.In(str(p) for p in uuids)},
86+
)
87+
88+
def _actualize_new_passwords(
89+
self, passwords: list[models.Password] | None = None
90+
) -> None:
91+
"""Actualize new passwords."""
92+
passwords = passwords or self._get_new_passwords()
93+
94+
if len(passwords) == 0:
95+
return
96+
97+
# Just create resources for new passwords
98+
for password in passwords:
99+
password_resource = password.to_ua_resource(sc.PASSWORD_KIND)
100+
try:
101+
password_resource.insert()
102+
password.status = sc.SecretStatus.IN_PROGRESS.value
103+
password.save()
104+
105+
# TODO(akremenetsky): Improve this snippet in the future
106+
password_resource.tracked_at = password.updated_at
107+
password_resource.status = password.status
108+
password_resource.update()
109+
LOG.info(
110+
"Password resource %s created", password_resource.uuid
111+
)
112+
except Exception:
113+
LOG.exception(
114+
"Error creating password resource %s", password.uuid
115+
)
116+
117+
def _actualize_changed_passwords(self) -> None:
118+
"""Actualize passwords changed by user."""
119+
changed_passwords = {p.uuid: p for p in self._get_changed_passwords()}
120+
121+
if len(changed_passwords) == 0:
122+
return
123+
124+
password_resources = ua_models.TargetResource.objects.get_all(
125+
filters={
126+
"uuid": dm_filters.In(
127+
str(p) for p in changed_passwords.keys()
128+
),
129+
"kind": dm_filters.EQ(sc.PASSWORD_KIND),
130+
}
131+
)
132+
133+
# Update every resource in accordance with the new password
134+
for resource in password_resources:
135+
password = changed_passwords[resource.uuid]
136+
137+
# TODO(akremenetsky): Need to clear the password container
138+
# before converting
139+
new_resource = password.to_ua_resource(
140+
sc.PASSWORD_KIND, tracked_at=resource.updated_at
141+
)
142+
143+
# Update the original resource
144+
resource.update_value(new_resource)
145+
try:
146+
resource.update()
147+
LOG.debug("Password resource %s updated", resource.uuid)
148+
except Exception:
149+
LOG.exception(
150+
"Error updating password resource %s", resource.uuid
151+
)
152+
153+
def _actualize_outdated_password(
154+
self,
155+
password: models.Password,
156+
target_resource: ua_models.TargetResource,
157+
actual_resource: ua_models.Resource,
158+
) -> None:
159+
"""Actualize outdated password."""
160+
saved_password = ua_models.Resource.from_ua_resource(actual_resource)
161+
162+
# Actualize password
163+
password.status = saved_password.status
164+
password.password = saved_password.password
165+
password.save()
166+
167+
# Actualize resource
168+
target_resource.status = saved_password.status
169+
target_resource.tracked_at = saved_password.updated_at
170+
target_resource.update()
171+
172+
def _actualize_outdated_passwords(self) -> None:
173+
"""Actualize outdated passwords.
174+
175+
It means some changes oscurred in the system and the passwords
176+
are outdated now. For instance, their status is incorrect.
177+
"""
178+
resource_map = self._get_outdated_resources()
179+
180+
if len(resource_map) == 0:
181+
return
182+
183+
passwords = self._get_outdated_passwords(tuple(resource_map.keys()))
184+
185+
for password in passwords:
186+
target, actual = resource_map[password.uuid]
187+
try:
188+
self._actualize_outdated_password(password, target, actual)
189+
LOG.debug("Password %s actualized", password.uuid)
190+
except Exception:
191+
LOG.exception("Error actualizing password %s", password.uuid)
192+
193+
def _actualize_deleted_passwords(self) -> None:
194+
"""Actualize passwords deleted by user."""
195+
deleted_passwords = self._get_deleted_passwords()
196+
197+
if len(deleted_passwords) == 0:
198+
return
199+
200+
for password in deleted_passwords:
201+
try:
202+
password.delete()
203+
LOG.debug("Outdated resource %s deleted", password.uuid)
204+
except Exception:
205+
LOG.exception("Error deleting resource %s", password.uuid)
206+
207+
def _iteration(self) -> None:
208+
with contexts.Context().session_manager():
209+
try:
210+
self._actualize_new_passwords()
211+
except Exception:
212+
LOG.exception("Error actualizing new passwords")
213+
214+
try:
215+
self._actualize_changed_passwords()
216+
except Exception:
217+
LOG.exception("Error actualizing changed passwords")
218+
219+
try:
220+
self._actualize_outdated_passwords()
221+
except Exception:
222+
LOG.exception("Error actualizing outdated passwords")
223+
224+
try:
225+
self._actualize_deleted_passwords()
226+
except Exception:
227+
LOG.exception("Error actualizing deleted passwords")

genesis_core/tests/functional/service/test_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424
from genesis_core.config.dm import models
2525

2626

27-
class TestConfigService:
27+
class TestConfigServiceBuilder:
2828

2929
def setup_method(self) -> None:
3030
# Run service
31-
self._service = service.ConfigService()
31+
self._service = service.ConfigServiceBuilder()
3232

3333
def teardown_method(self) -> None:
3434
pass

genesis_core/user_api/secret/__init__.py

Whitespace-only changes.

genesis_core/user_api/secret/api/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)