Skip to content

Commit 1c9a13c

Browse files
feat(ua): let external agents register and issue their node key (#517)
- POST /v1/ua/agents/ now accepts creation (previously the route only allowed GET/FILTER), so an agent can register with its own uuid, node, and capabilities. - Adds an issue_key action on that resource (/v1/ua/agents/<uuid>/actions/issue_key/invoke), gated by its own agent.ua.issue_key IAM permission. It returns the node's encryption key, generating one only if none exists yet — so several agents registered on the same node all get back the same key, and the operation is race-safe if two of them ask at once. - Seeds the new agent.ua.create / agent.ua.issue_key permissions via a migration. - Fixes Node.insert() to reuse an existing key for its uuid instead of unconditionally creating a new one — needed because a machine can be both a plain compute Node and a local hypervisor's node, and both provision a key for the same uuid; previously the second registration would crash on the unique constraint. - Bootstrap's existing trusted key-delivery path is untouched; this adds a separate, permissioned self-service path for agents that aren't provisioned through bootstrap. Covered by new functional tests (registration, key issuance, key sharing across agents on one node, permission enforcement) and unit tests.
1 parent 012137d commit 1c9a13c

9 files changed

Lines changed: 223 additions & 18 deletions

File tree

exordos/manifests/core.yaml.j2

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ version: "{{ version }}"
55
api_version: "v1"
66

77
resources:
8+
9+
# IAM
10+
$core.iam.permissions:
11+
ua_agent_create:
12+
name: "agent.ua.create"
13+
description: "Register a Universal Agent"
14+
ua_agent_issue_key:
15+
name: "agent.ua.issue_key"
16+
description: "Issue or fetch a Universal Agent node encryption key"
17+
818
$core.vs.profiles:
919
develop:
1020
name: "develop"

exordos_core/compute/dm/models.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import typing as tp
1919
import uuid as sys_uuid
2020

21-
from gcl_sdk.agents.universal.api import crypto as ua_crypto
2221
from gcl_sdk.agents.universal.dm import models as ua_models
2322
from gcl_sdk.infra.dm import models as infra_models
2423
import netaddr
@@ -427,13 +426,10 @@ def insert(self, session=None):
427426
volume = Volume.restore_from_simple_view(**view)
428427
volume.insert(session=session)
429428

430-
# Generate private key for the node
431-
_, key_base64 = ua_crypto.generate_key_base64()
432-
private_key = ua_models.NodeEncryptionKey(
433-
uuid=self.uuid,
434-
private_key=key_base64,
435-
)
436-
private_key.insert(session=session)
429+
# A key may already exist for this uuid (e.g. it's also
430+
# registered as a local hypervisor's node, which provisions its
431+
# own key the same way) - reuse it instead of conflicting.
432+
ua_models.NodeEncryptionKey.get_or_create(self.uuid, session=session)
437433

438434
def get_agent_private_key(self):
439435
enc_key = ua_models.NodeEncryptionKey.objects.get_one(

exordos_core/tests/functional/restapi/compute/test_hypervisor_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
# under the License.
1616

1717
import typing as tp
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
22+
from gcl_sdk.agents.universal.api import crypto as ua_crypto
23+
from gcl_sdk.agents.universal.dm import models as ua_models
2124
import pytest
25+
from restalchemy.dm import filters as dm_filters
2226

2327
from exordos_core.compute import constants as nc
2428

@@ -312,3 +316,34 @@ def test_hypervisors_add_same_connection_uri(
312316
client.delete(
313317
client.build_resource_uri(["compute", "hypervisors", hypervisor1["uuid"]])
314318
)
319+
320+
def test_node_reuses_an_existing_node_key(
321+
self,
322+
node_factory: tp.Callable,
323+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
324+
auth_user_admin: iam_clients.GenesisCoreAuth,
325+
):
326+
# A key may already exist for a node's uuid from another source
327+
# (e.g. it's also registered as a local hypervisor's node) -
328+
# registering the Node must reuse it instead of conflicting on
329+
# insert.
330+
node_uuid = sys_uuid.uuid4()
331+
_, private_key = ua_crypto.generate_key_base64()
332+
existing_key = ua_models.NodeEncryptionKey(
333+
uuid=node_uuid, private_key=private_key
334+
)
335+
existing_key.insert()
336+
337+
client = user_api_client(auth_user_admin)
338+
node = node_factory(uuid=node_uuid)
339+
response = client.post(
340+
client.build_collection_uri(["compute", "nodes"]), json=node
341+
)
342+
assert response.status_code == 201
343+
344+
key = ua_models.NodeEncryptionKey.objects.get_one(
345+
filters={"uuid": dm_filters.EQ(node_uuid)}
346+
)
347+
assert key.private_key == private_key
348+
349+
client.delete(client.build_resource_uri(["compute", "nodes", str(node_uuid)]))

exordos_core/tests/functional/restapi/ua/test_ua_api.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class TestUaAgentsApi:
2929
def _agent_factory(
3030
uuid: tp.Optional[sys_uuid.UUID] = None,
3131
name: tp.Optional[str] = None,
32+
node: tp.Optional[sys_uuid.UUID] = None,
3233
status: str = "ACTIVE",
3334
**kwargs,
3435
) -> sys_uuid.UUID:
@@ -41,7 +42,7 @@ def _agent_factory(
4142
name=name,
4243
capabilities={"capabilities": ["test_capability"]},
4344
facts={"facts": []},
44-
node=sys_uuid.uuid4(),
45+
node=node or sys_uuid.uuid4(),
4546
status=status,
4647
**kwargs,
4748
)
@@ -147,6 +148,108 @@ def test_user_with_permission_can_list(
147148
uuids = [item["uuid"] for item in output]
148149
assert str(agent_uuid) in uuids
149150

151+
def test_admin_register_agent(
152+
self,
153+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
154+
auth_user_admin: iam_clients.GenesisCoreAuth,
155+
):
156+
agent_uuid = sys_uuid.uuid4()
157+
node_uuid = sys_uuid.uuid4()
158+
client = user_api_client(auth_user_admin)
159+
url = client.build_collection_uri(["ua", "agents"])
160+
161+
response = client.post(
162+
url,
163+
json={
164+
"uuid": str(agent_uuid),
165+
"name": "external-agent",
166+
"node": str(node_uuid),
167+
"capabilities": {"capabilities": ["test_capability"]},
168+
"facts": {"facts": []},
169+
},
170+
)
171+
output = response.json()
172+
173+
assert response.status_code == 201
174+
assert output["uuid"] == str(agent_uuid)
175+
assert output["node"] == str(node_uuid)
176+
177+
def test_issue_key_creates_a_key(
178+
self,
179+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
180+
auth_user_admin: iam_clients.GenesisCoreAuth,
181+
):
182+
agent_uuid = self._agent_factory()
183+
client = user_api_client(auth_user_admin)
184+
url = client.build_resource_uri(
185+
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
186+
)
187+
188+
response = client.post(url)
189+
output = response.json()
190+
191+
assert response.status_code == 200
192+
assert output["key"]
193+
194+
def test_issue_key_is_shared_by_agents_on_the_same_node(
195+
self,
196+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
197+
auth_user_admin: iam_clients.GenesisCoreAuth,
198+
):
199+
node_uuid = sys_uuid.uuid4()
200+
agent1_uuid = self._agent_factory(node=node_uuid)
201+
agent2_uuid = self._agent_factory(node=node_uuid)
202+
client = user_api_client(auth_user_admin)
203+
204+
response1 = client.post(
205+
client.build_resource_uri(
206+
["ua", "agents", str(agent1_uuid), "actions", "issue_key", "invoke"]
207+
)
208+
)
209+
response2 = client.post(
210+
client.build_resource_uri(
211+
["ua", "agents", str(agent2_uuid), "actions", "issue_key", "invoke"]
212+
)
213+
)
214+
215+
assert response1.json()["key"] == response2.json()["key"]
216+
217+
def test_issue_key_nonadmin_no_access(
218+
self,
219+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
220+
auth_test1_user: iam_clients.GenesisCoreAuth,
221+
):
222+
agent_uuid = self._agent_factory()
223+
client = user_api_client(auth_test1_user)
224+
url = client.build_resource_uri(
225+
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
226+
)
227+
228+
with pytest.raises(bazooka_exc.ForbiddenError):
229+
client.post(url)
230+
231+
def test_issue_key_user_with_permission_can_issue(
232+
self,
233+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
234+
auth_test1_user: iam_clients.GenesisCoreAuth,
235+
):
236+
agent_uuid = self._agent_factory()
237+
client = user_api_client(
238+
auth_test1_user,
239+
permissions=[
240+
"agent.ua.read",
241+
"agent.ua.issue_key",
242+
],
243+
)
244+
url = client.build_resource_uri(
245+
["ua", "agents", str(agent_uuid), "actions", "issue_key", "invoke"]
246+
)
247+
248+
response = client.post(url)
249+
250+
assert response.status_code == 200
251+
assert response.json()["key"]
252+
150253

151254
class TestUaResourcesApi:
152255
@staticmethod
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 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+
import uuid as sys_uuid
17+
from unittest.mock import patch
18+
19+
from gcl_sdk.agents.universal.dm import models as ua_models
20+
from gcl_sdk.infra.dm import models as infra_models
21+
22+
from exordos_core.compute.dm import models
23+
24+
25+
class TestNodeInsert:
26+
def test_reuses_an_existing_key(self):
27+
# A key may already exist for this uuid (e.g. it's also a local
28+
# hypervisor's node, which provisions its own key the same way) -
29+
# go through get_or_create instead of blindly inserting a fresh
30+
# one and conflicting on the unique node uuid.
31+
node = models.Node(
32+
cores=1,
33+
ram=1024,
34+
disk_spec=infra_models.RootDiskSpec(image="ubuntu_24.04"),
35+
project_id=sys_uuid.uuid4(),
36+
)
37+
38+
with (
39+
patch.object(models.orm.SQLStorableMixin, "insert"),
40+
patch.object(ua_models.NodeEncryptionKey, "get_or_create") as get_or_create,
41+
):
42+
node.insert()
43+
44+
get_or_create.assert_called_once_with(node.uuid, session=None)

exordos_core/user_api/ua/controllers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from gcl_iam.api import controllers as iam_controllers
1818
from gcl_sdk.agents.universal.dm import models as ua_models
19+
from restalchemy.api import actions
1920
from restalchemy.api import controllers as ra_controllers
2021
from restalchemy.api import resources
2122

@@ -36,6 +37,14 @@ class AgentController(
3637
convert_underscore=False,
3738
)
3839

40+
@actions.post
41+
def issue_key(self, resource: ua_models.UniversalAgent):
42+
self._enforce("issue_key")
43+
44+
enc_key = ua_models.NodeEncryptionKey.get_or_create(resource.node)
45+
46+
return {"key": enc_key.private_key}
47+
3948

4049
class ResourceController(
4150
iam_controllers.PolicyBasedController,

exordos_core/user_api/ua/routes.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,20 @@
1919
from exordos_core.user_api.ua import controllers
2020

2121

22+
class IssueKeyAction(routes.Action):
23+
"""Handler for /v1/ua/agents/<uuid>/actions/issue_key/invoke endpoint"""
24+
25+
__controller__ = controllers.AgentController
26+
27+
2228
class AgentsRoute(routes.Route):
2329
"""Handler for /v1/ua/agents/ endpoint"""
2430

25-
__allow_methods__ = [routes.FILTER, routes.GET]
31+
__allow_methods__ = [routes.FILTER, routes.GET, routes.CREATE]
2632
__controller__ = controllers.AgentController
2733

34+
issue_key = routes.action(IssueKeyAction, invoke=True)
35+
2836

2937
class ResourcesRoute(routes.Route):
3038
"""Handler for /v1/ua/resources/ endpoint"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"Jinja2>=3.1.5,<4.0.0", # BSD License (BSD-3-Clause)
3030
"izulu>=0.50.0,<1.0.0", # MIT License
3131
"gcl_iam>=1.3.2,<2.0.0", # Apache-2.0
32-
"gcl_sdk>=3.1.0,<4.0.0", # Apache-2.0
32+
"gcl_sdk>=3.2.2,<4.0.0", # Apache-2.0
3333
"gcl_certbot_plugin>=0.0.9,<1.0.0", # Apache-2.0
3434
"pyotp>=2.9.0,<3.0.0", # MIT License
3535
"pyyaml>=6.0.0,<7.0.0", # MIT

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)