Skip to content

Commit 119041f

Browse files
committed
Secret manager: Passwords
Added the initial part of Secret Manager to manager passwords. The first implementation is supported only plain passwords but a particular `constructor` interface allow to implement more strict solutions in the future. The secret manager follows the universal agent way so actual password generation and saving is on the driver side. Single driver `PasswordCapabilityDriver' has been added in the first implementation that uses the PG database as a storage for passwords. An example, of adding a password: Request: ```curl curl --location 'http://10.20.0.2:11010/v1/secret/passwords/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <TOKEN>' \ --data '{ "name": "my-password", "project_id": "c27f59fe-d3d3-4625-804c-5c9fd3bb3142", "method": "AUTO_HEX", "constructor": { "kind": "plain" } }' ``` Response: ```curl { "uuid": "985dad4b-161c-432c-8d9f-be78763df758", "created_at": "2025-06-25T20:39:50.095203Z", "updated_at": "2025-06-25T20:39:56.335798Z", "project_id": "c27f59fe-d3d3-4625-804c-5c9fd3bb3142", "name": "my-password", "description": "", "method": "AUTO_HEX", "status": "ACTIVE", "constructor": { "kind": "plain" }, "value": "755eca35ece5bd921852c72996d1a790" } ``` There are two supported method for generation: AUTO_HEX, AUTO_URL_SAFE Other improvements: - Significant fixes for the configuraiton service, integration with SDK. - Fixes for Element Manager for integration with universal agent. - A tiny fix for DNS for tests. Signed-off-by: Anton Kremenetsky <anton.kremenetsky@gmail.com>
1 parent 5f78c98 commit 119041f

34 files changed

Lines changed: 1339 additions & 113 deletions

etc/genesis_universal_agent/genesis_universal_agent.conf

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ connection_pool_size = 2
1111
[universal_agent]
1212
orch_endpoint = http://localhost:11011
1313
status_endpoint = http://localhost:11012
14-
caps_drivers = CoreCapabilityDriver
14+
caps_drivers = CoreCapabilityDriver,PasswordCapabilityDriver
1515

1616

1717
[universal_agent_scheduler]
18-
capabilities = em_core_*
18+
capabilities = em_core_*,password
1919

2020

2121
[CoreCapabilityDriver]
@@ -25,3 +25,4 @@ user_api_base_url = http://localhost:11010
2525
project_id = 12345678-c625-4fee-81d5-f691897b8142
2626
em_core_compute_nodes = /v1/nodes/
2727
em_core_config_configs = /v1/config/configs/
28+
em_core_secret_passwords = /v1/secret/passwords/
File renamed without changes.

genesis_core/agent/universal/drivers/secret/__init__.py

Whitespace-only changes.

genesis_core/agent/universal/drivers/secret/backend/__init__.py

Whitespace-only changes.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 secrets
19+
import logging
20+
import typing as tp
21+
22+
from restalchemy.dm import filters as dm_filters
23+
from restalchemy.storage import exceptions as ra_exc
24+
from gcl_sdk.agents.universal.dm import models
25+
from gcl_sdk.agents.universal.clients.backend import base
26+
from gcl_sdk.agents.universal.clients.backend import exceptions
27+
28+
from genesis_core.secret import constants as sc
29+
from genesis_core.secret.dm import models as secret_dm
30+
from genesis_core.agent.universal.drivers.secret.dm import models as driver_dm
31+
32+
33+
LOG = logging.getLogger(__name__)
34+
35+
36+
class DatabaseSecretBackendClient(base.AbstractBackendClient):
37+
"""Secret Backend client based on SQL database."""
38+
39+
def get(self, resource: models.Resource) -> dict[str, tp.Any]:
40+
"""Get the resource value in dictionary format."""
41+
try:
42+
driver_password = driver_dm.Password.objects.get_one(
43+
filters={
44+
"uuid": dm_filters.EQ(resource.uuid),
45+
},
46+
)
47+
except ra_exc.RecordNotFound:
48+
raise exceptions.ResourceNotFound(resource=resource)
49+
50+
return driver_password.meta
51+
52+
def create(self, resource: models.Resource) -> dict[str, tp.Any]:
53+
"""Creates the resource. Returns the created resource."""
54+
try:
55+
self.get(resource)
56+
except exceptions.ResourceNotFound:
57+
pass
58+
else:
59+
raise exceptions.ResourceAlreadyExists(resource=resource)
60+
61+
password = secret_dm.Password.from_ua_resource(resource)
62+
63+
# Validate structure of password model
64+
if (
65+
sc.SecretMethod[password.method].is_auto
66+
and password.value is not None
67+
):
68+
raise ValueError("Cannot create auto-generated password.")
69+
70+
if (
71+
not sc.SecretMethod[password.method].is_auto
72+
and password.value is None
73+
):
74+
raise ValueError("Cannot create non-auto-generated password.")
75+
76+
# Generate plain password
77+
if sc.SecretMethod[password.method].is_auto:
78+
if password.method == sc.SecretMethod.AUTO_HEX:
79+
plain_password = secrets.token_hex(16)
80+
elif password.method == sc.SecretMethod.AUTO_URL_SAFE:
81+
plain_password = secrets.token_urlsafe(16)
82+
else:
83+
raise ValueError("Unknown auto-generated password method.")
84+
else:
85+
plain_password = password.value
86+
87+
# Build password from the plain view
88+
pass_value = password.constructor.build(plain_password)
89+
90+
# Build storagable password and save
91+
driver_password = driver_dm.Password.from_password_resource(
92+
resource, pass_value
93+
)
94+
driver_password.save()
95+
return driver_password.meta
96+
97+
def update(self, resource: models.Resource) -> dict[str, tp.Any]:
98+
"""Update the resource. Returns the updated resource."""
99+
100+
# The simplest implementation. Update via recreation.
101+
self.delete(resource)
102+
return self.create(resource)
103+
104+
def list(self, kind: str, **kwargs) -> list[dict[str, tp.Any]]:
105+
"""Lists all resources by kind."""
106+
secrets = driver_dm.Password.objects.get_all()
107+
return [s.meta for s in secrets]
108+
109+
def delete(self, resource: models.Resource) -> None:
110+
"""Delete the resource."""
111+
try:
112+
self.get(resource)
113+
except exceptions.ResourceNotFound:
114+
raise exceptions.ResourceNotFound(resource=resource)
115+
116+
password = driver_dm.Password.objects.get_one(
117+
filters={
118+
"uuid": dm_filters.EQ(resource.uuid),
119+
}
120+
)
121+
password.delete()

genesis_core/agent/universal/drivers/secret/dm/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 typing as tp
19+
20+
from restalchemy.dm import properties
21+
from restalchemy.dm import models
22+
from restalchemy.dm import types
23+
from restalchemy.storage.sql import orm
24+
from gcl_sdk.agents.universal.dm import models as ua_models
25+
26+
from genesis_core.secret import constants as sc
27+
from genesis_core.secret.dm import models as secret_dm
28+
29+
30+
class Password(
31+
models.ModelWithUUID,
32+
models.ModelWithTimestamp,
33+
orm.SQLStorableMixin,
34+
):
35+
__tablename__ = "storage_passwords"
36+
37+
status = properties.property(
38+
types.Enum([s.value for s in sc.SecretStatus]),
39+
default=sc.SecretStatus.NEW.value,
40+
)
41+
value = properties.property(
42+
types.String(min_length=1, max_length=512),
43+
required=True,
44+
)
45+
# Some additional metadata about the secret
46+
meta = properties.property(types.Dict(), default=lambda: {})
47+
48+
@classmethod
49+
def from_password_resource(
50+
cls, resource: ua_models.TargetResource, password_value: str
51+
) -> Password:
52+
meta = resource.value.copy()
53+
meta["value"] = password_value
54+
meta["status"] = sc.SecretStatus.ACTIVE.value
55+
56+
return cls(
57+
uuid=resource.uuid,
58+
value=password_value,
59+
status=sc.SecretStatus.ACTIVE.value,
60+
meta=meta,
61+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
20+
from gcl_sdk.agents.universal.drivers import direct
21+
from gcl_sdk.agents.universal.storage import fs
22+
23+
from genesis_core.agent.universal.drivers.secret.backend import db as db_back
24+
25+
26+
LOG = logging.getLogger(__name__)
27+
28+
AGENT_WORK_DIR = "/var/lib/genesis/universal_agent/"
29+
30+
31+
class PasswordCapabilityDriver(direct.DirectAgentDriver):
32+
"""Password capability driver."""
33+
34+
def __init__(self):
35+
storage = fs.FileAgentStorage(
36+
AGENT_WORK_DIR, "password_cap_storage.json"
37+
)
38+
client = db_back.DatabaseSecretBackendClient()
39+
40+
super().__init__(storage=storage, client=client)
41+
42+
def get_capabilities(self) -> list[str]:
43+
"""Returns a list of capabilities supported by the driver."""
44+
return ["password"]

genesis_core/config/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import typing as tp
1919

2020
DEFAULT_SQL_LIMIT = 100
21+
CONFIG_KIND = "config"
22+
RENDER_KIND = "render"
2123

2224

2325
class ConfigStatus(str, enum.Enum):

genesis_core/config/dm/models.py

Lines changed: 7 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,10 @@
1919
import uuid as sys_uuid
2020

2121
from restalchemy.dm import models
22-
from restalchemy.dm import filters as dm_filters
2322
from restalchemy.dm import properties
2423
from restalchemy.dm import types
2524
from restalchemy.dm import types_dynamic
2625
from restalchemy.storage.sql import orm
27-
from restalchemy.storage.sql import engines
2826

2927
from gcl_sdk.agents.universal.dm import models as ua_models
3028

@@ -135,6 +133,7 @@ class Config(
135133
cm.ModelWithFullAsset,
136134
orm.SQLStorableMixin,
137135
ua_models.TargetResourceMixin,
136+
ua_models.TargetResourceSQLStorableMixin,
138137
):
139138
__tablename__ = "config_configs"
140139

@@ -205,86 +204,29 @@ def render(self, node: sys_uuid.UUID) -> ua_models.TargetResource:
205204
)
206205

207206
resource = render.to_ua_resource("render", master=self.uuid)
208-
resource.calculate_hash()
207+
resource.status = cc.ConfigStatus.IN_PROGRESS.value
209208
return resource
210209

211210
@classmethod
212211
def get_new_configs(
213212
cls, limit: int = cc.DEFAULT_SQL_LIMIT
214213
) -> list["Config"]:
215-
expression = (
216-
"SELECT "
217-
" config_configs.uuid as uuid "
218-
"FROM config_configs LEFT JOIN ua_target_resources ON "
219-
" config_configs.uuid = ua_target_resources.uuid "
220-
"WHERE ua_target_resources.uuid is NULL "
221-
"LIMIT %s;"
222-
)
223-
params = (limit,)
224-
225-
engine = engines.engine_factory.get_engine()
226-
with engine.session_manager() as session:
227-
curs = session.execute(expression, params)
228-
response = curs.fetchall()
229-
230-
if not response:
231-
return []
232-
233-
return cls.objects.get_all(
234-
filters={"uuid": dm_filters.In(str(r["uuid"]) for r in response)},
235-
)
214+
return cls.get_new_entities(cls.__tablename__, cc.CONFIG_KIND, limit)
236215

237216
@classmethod
238217
def get_updated_configs(
239218
cls, limit: int = cc.DEFAULT_SQL_LIMIT
240219
) -> list["Config"]:
241-
expression = (
242-
"SELECT "
243-
" config_configs.uuid as uuid "
244-
"FROM config_configs INNER JOIN ua_target_resources ON "
245-
" config_configs.uuid = ua_target_resources.uuid "
246-
"WHERE config_configs.updated_at != ua_target_resources.tracked_at "
247-
"LIMIT %s;"
248-
)
249-
params = (limit,)
250-
251-
engine = engines.engine_factory.get_engine()
252-
with engine.session_manager() as session:
253-
curs = session.execute(expression, params)
254-
response = curs.fetchall()
255-
256-
if not response:
257-
return []
258-
259-
return cls.objects.get_all(
260-
filters={"uuid": dm_filters.In(str(r["uuid"]) for r in response)},
220+
return cls.get_updated_entities(
221+
cls.__tablename__, cc.CONFIG_KIND, limit
261222
)
262223

263224
@classmethod
264225
def get_deleted_config_renders(
265226
cls, limit: int = cc.DEFAULT_SQL_LIMIT
266227
) -> list[ua_models.TargetResource]:
267-
expression = (
268-
"SELECT "
269-
" ua_target_resources.uuid as uuid "
270-
"FROM ua_target_resources LEFT JOIN config_configs ON "
271-
" ua_target_resources.uuid = config_configs.uuid "
272-
"WHERE ua_target_resources.kind = 'config' "
273-
" AND config_configs.uuid is NULL "
274-
"LIMIT %s;"
275-
)
276-
params = (limit,)
277-
278-
engine = engines.engine_factory.get_engine()
279-
with engine.session_manager() as session:
280-
curs = session.execute(expression, params)
281-
response = curs.fetchall()
282-
283-
if not response:
284-
return []
285-
286-
return ua_models.TargetResource.objects.get_all(
287-
filters={"uuid": dm_filters.In(str(r["uuid"]) for r in response)},
228+
return cls.get_deleted_target_resources(
229+
cls.__tablename__, cc.CONFIG_KIND, limit
288230
)
289231

290232

0 commit comments

Comments
 (0)