Skip to content

Commit 5dcaf1d

Browse files
committed
feat(audit): integrate resource audit delivery
1 parent 81f1c91 commit 5dcaf1d

10 files changed

Lines changed: 318 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ tox -e ruff-check # Check code style
9999
tox -e mypy # Type checking
100100
```
101101

102+
Do not store individual test-run reports, dated stand snapshots, or execution
103+
history in this repository. Keep only current test plans, runbooks, test IDs,
104+
automated tests, acceptance criteria, and concise coverage status here. Store
105+
sanitized historical run reports in
106+
`github/exordos/cassi/docs/test-runs/exordos-core/` and never include secrets,
107+
production payloads, full logs, databases, or binary artifacts.
108+
102109
### Build Commands
103110

104111
```bash

etc/exordos_core/exordos_core.conf.j2

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,14 @@ enabled = False
5656
# exordos_notification element, which drops an [events] override into
5757
# /etc/exordos_core/exordos_core.d/ (loaded via --config-dir by ec-user-api).
5858

59+
[audit_delivery]
60+
enabled = False
61+
# endpoint = http://audit.local.genesis-core.tech:8080/
62+
# api_version = v1
63+
# auth_token =
64+
# timeout = 5
65+
# batch_size = 100
66+
#
67+
# The separate core_audit element enables the worker by installing a
68+
# config drop-in under /etc/exordos_core/exordos_core.d/ and restarting
69+
# ec-gservice. Installing the central audit element alone does not enable it.

exordos/core-audit.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License.
5+
6+
# Build the Core-to-Audit integration separately because the rendered output
7+
# contains the manually supplied bearer token. Keep the output protected and
8+
# remove it after deploy.
9+
build:
10+
elements:
11+
- manifest: manifests/core_audit.yaml.j2
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License.
5+
6+
name: "core_audit"
7+
description: "Configure Exordos Core audit delivery"
8+
schema_version: 1
9+
version: "{{ version | default('1.0.0') }}"
10+
api_version: "v1"
11+
12+
requirements:
13+
core:
14+
from_version: "0.0.0"
15+
audit:
16+
from_version: "0.0.0"
17+
18+
resources:
19+
$core.config.configs:
20+
core_audit_config:
21+
project_id: "12345678-c625-4fee-81d5-f691897b8142"
22+
path: /etc/exordos_core/exordos_core.d/90-audit-delivery.conf
23+
mode: "0600"
24+
owner: root
25+
group: root
26+
target:
27+
kind: node_set
28+
# core_set has a stable UUID in the core element manifest.
29+
node_set: "70c88222-b4d9-46c3-9340-aa5bfaaa4b94"
30+
on_change:
31+
kind: shell
32+
command: systemctl restart ec-gservice
33+
body:
34+
kind: text
35+
content: |
36+
[audit_delivery]
37+
enabled = true
38+
endpoint = http://audit.local.genesis-core.tech:8080/
39+
api_version = v1
40+
auth_token = {{ audit_token }}
41+
timeout = 5
42+
batch_size = 100
43+
44+
# The token is supplied manually until exordos_core#470 adds declarative
45+
# service-token issuance and rotation. Do not add exports: this integration
46+
# element owns no public resource contract.

exordos_core/cmd/gservice.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import logging
1818
import sys
1919

20+
from gcl_sdk.audit import opts as audit_opts
2021
from gcl_sdk.events import opts as sdk_opts
2122
from oslo_config import cfg
2223
from restalchemy.common import config_opts as ra_config_opts
@@ -40,6 +41,7 @@
4041
CONF = cfg.CONF
4142
ra_config_opts.register_posgresql_db_opts(CONF)
4243
sdk_opts.register_event_opts(CONF)
44+
audit_opts.register_audit_delivery_opts(CONF)
4345

4446
CONF.register_cli_opts(cli_opts, DOMAIN)
4547

exordos_core/compute/dm/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from gcl_sdk.agents.universal.api import crypto as ua_crypto
2222
from gcl_sdk.agents.universal.dm import models as ua_models
23+
from gcl_sdk.audit.dm import models as audit_models
2324
from gcl_sdk.infra.dm import models as infra_models
2425
import netaddr
2526
from restalchemy.dm import filters as dm_filters
@@ -299,11 +300,14 @@ def set_active(self):
299300

300301

301302
class Node(
303+
audit_models.AuditLogSQLStorableMixin,
302304
infra_models.Node,
303305
orm.SQLStorableWithJSONFieldsMixin,
304306
):
305307
__tablename__ = "nodes"
306308
__jsonfields__ = ["default_network"]
309+
__audit_service_name__ = nc.POLICY_SERVICE_NAME
310+
__audit_resource_type__ = "node"
307311

308312
uuid = properties.property(
309313
types.UUID(),

exordos_core/gservice/service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from gcl_sdk.agents.universal.drivers import core as ua_core_drivers
2626
from gcl_sdk.agents.universal.services import agent as ua_agent_service
2727
from gcl_sdk.agents.universal.services import scheduler as ua_scheduler_service
28+
from gcl_sdk.audit import opts as audit_opts
29+
from gcl_sdk.audit.services import senders as audit_senders
2830
from gcl_sdk.events import constants as event_c
2931
from gcl_sdk.events.services import senders
3032
from oslo_config import cfg
@@ -194,6 +196,12 @@ def __init__(self, iter_min_period=3, iter_pause=0.1):
194196
event_sender = senders.EventSenderService.build_from_config()
195197
else:
196198
event_sender = None
199+
if audit_opts.get_audit_delivery_config().enabled:
200+
audit_sender = audit_senders.AuditSenderService.build_from_config(
201+
iter_min_period=iter_min_period,
202+
)
203+
else:
204+
audit_sender = None
197205
em_builder = em_builders.ElementManagerBuilder(iter_min_period=iter_min_period)
198206
janitor = janitor_service.ExpiredEmailConfirmationCodeJanitorService(
199207
iter_min_period=60 * 60,
@@ -233,6 +241,8 @@ def __init__(self, iter_min_period=3, iter_pause=0.1):
233241
]
234242
if event_sender is not None:
235243
self._services.append(event_sender)
244+
if audit_sender is not None:
245+
self._services.append(audit_sender)
236246
self._next_run_times = {id(s): 0 for s in self._services}
237247

238248
def _setup(self):
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
17+
import typing as tp
18+
import uuid as sys_uuid
19+
20+
from gcl_iam.tests.functional import clients as iam_clients
21+
22+
23+
class TestAuditApi:
24+
def test_node_audit_lifecycle(
25+
self,
26+
node_factory: tp.Callable,
27+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
28+
auth_user_admin: iam_clients.GenesisCoreAuth,
29+
):
30+
client = user_api_client(auth_user_admin)
31+
node = node_factory()
32+
node_url = client.build_collection_uri(["compute", "nodes"])
33+
34+
response = client.post(node_url, json=node)
35+
assert response.status_code == 201
36+
37+
resource_url = client.build_resource_uri(
38+
["compute", "nodes", node["uuid"]]
39+
)
40+
response = client.put(resource_url, json={"cores": 2, "ram": 2048})
41+
assert response.status_code == 200
42+
43+
response = client.delete(resource_url)
44+
assert response.status_code == 204
45+
46+
response = client.get(
47+
client.build_collection_uri(["security", "audit"]),
48+
params={"resource_uuid": node["uuid"]},
49+
)
50+
assert response.status_code == 200
51+
52+
events = response.json()
53+
assert len(events) == 3
54+
assert {event["action"] for event in events} == {
55+
"create",
56+
"update",
57+
"delete",
58+
}
59+
60+
events_by_action = {event["action"]: event for event in events}
61+
for event in events:
62+
assert event["service_name"] == "compute"
63+
assert event["resource_type"] == "node"
64+
assert event["resource_uuid"] == node["uuid"]
65+
assert event["project_id"] == node["project_id"]
66+
assert event["actor_user_uuid"] == auth_user_admin.uuid
67+
68+
assert events_by_action["create"]["snapshot"]["cores"] == 1
69+
assert events_by_action["update"]["snapshot"]["cores"] == 2
70+
assert events_by_action["update"]["snapshot"]["ram"] == 2048
71+
assert events_by_action["delete"]["snapshot"] is None
72+
73+
def test_audit_events_are_project_scoped(
74+
self,
75+
node_factory: tp.Callable,
76+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
77+
auth_user_admin: iam_clients.GenesisCoreAuth,
78+
auth_test1_p1_user: iam_clients.GenesisCoreAuth,
79+
auth_test2_p1_user: iam_clients.GenesisCoreAuth,
80+
):
81+
admin_client = user_api_client(auth_user_admin)
82+
nodes_url = admin_client.build_collection_uri(["compute", "nodes"])
83+
node_a = node_factory(project_id=sys_uuid.UUID(auth_test1_p1_user.project_id))
84+
node_b = node_factory(project_id=sys_uuid.UUID(auth_test2_p1_user.project_id))
85+
86+
assert admin_client.post(nodes_url, json=node_a).status_code == 201
87+
assert admin_client.post(nodes_url, json=node_b).status_code == 201
88+
89+
project_client = user_api_client(
90+
auth_test1_p1_user,
91+
permissions=["audit.events.read"],
92+
project_id=auth_test1_p1_user.project_id,
93+
)
94+
response = project_client.get(
95+
project_client.build_collection_uri(["security", "audit"]),
96+
)
97+
98+
assert response.status_code == 200
99+
assert {event["resource_uuid"] for event in response.json()} == {
100+
node_a["uuid"]
101+
}
102+
103+
for node in (node_a, node_b):
104+
admin_client.delete(
105+
admin_client.build_resource_uri(
106+
["compute", "nodes", node["uuid"]]
107+
)
108+
)

exordos_core/user_api/security/api/routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# License for the specific language governing permissions and limitations
1515
# under the License.
1616

17+
from gcl_sdk.audit.api import routes as audit_routes
1718
from restalchemy.api import routes
1819

1920
from exordos_core.user_api.security.api import controllers
@@ -31,4 +32,5 @@ class SecurityRoute(routes.Route):
3132
__controller__ = controllers.SecurityController
3233
__allow_methods__ = [routes.FILTER]
3334

35+
audit = routes.route(audit_routes.AuditRoute)
3436
rules = routes.route(RulesRoute)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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+
17+
import os
18+
import uuid as sys_uuid
19+
20+
from gcl_sdk import migrations as sdk_migrations
21+
from gcl_sdk.common import utils as sdk_utils
22+
from restalchemy.storage.sql import migrations
23+
24+
NS_UUID = sys_uuid.UUID("dfd0c604-607f-4260-981f-374f88435ea0")
25+
OWNER_ROLE_UUID = "726f6c65-0000-0000-0000-000000000002"
26+
27+
AUDIT_READ = "audit.events.read"
28+
AUDIT_READ_ALL = "audit.events.read_all"
29+
AUDIT_CREATE = "audit.events.create"
30+
SDK_MIGRATION_FILE_NAME = "0007-init-audit-events-4f3a2b"
31+
AUDIT_PERMISSIONS = (
32+
(AUDIT_CREATE, "Create events in the central audit service"),
33+
(AUDIT_READ, "Read audit events in the current project"),
34+
(AUDIT_READ_ALL, "Read audit events in all projects"),
35+
)
36+
37+
38+
def _u(name: str) -> str:
39+
return str(sys_uuid.uuid5(NS_UUID, name))
40+
41+
42+
class MigrationStep(migrations.AbstractMigrationStep):
43+
def __init__(self):
44+
self._depends = ["0064-init-repo-tables-645142.py"]
45+
46+
@property
47+
def migration_id(self):
48+
return "c4e5055c-880e-4150-a93e-6759d9277ad1"
49+
50+
@property
51+
def is_manual(self):
52+
return False
53+
54+
def _get_sdk_migration_engine(self):
55+
sdk_migration_path = os.path.dirname(sdk_migrations.__file__)
56+
return sdk_utils.MigrationEngine(migrations_path=sdk_migration_path)
57+
58+
def _create_permissions(self, session):
59+
for name, description in AUDIT_PERMISSIONS:
60+
session.execute(
61+
"""
62+
INSERT INTO iam_permissions (
63+
uuid, name, description
64+
) VALUES (
65+
%s, %s, %s
66+
)
67+
ON CONFLICT (uuid) DO NOTHING;
68+
""",
69+
(_u(name), name, description),
70+
)
71+
72+
def _create_bindings(self, session):
73+
session.execute(
74+
"""
75+
INSERT INTO iam_binding_permissions (
76+
uuid, role, permission, project_id
77+
) VALUES (
78+
gen_random_uuid(), %s, %s, NULL
79+
);
80+
""",
81+
(OWNER_ROLE_UUID, _u(AUDIT_READ)),
82+
)
83+
84+
def upgrade(self, session):
85+
migration_engine = self._get_sdk_migration_engine()
86+
migration_engine.apply_migration(SDK_MIGRATION_FILE_NAME, session)
87+
self._create_permissions(session)
88+
self._create_bindings(session)
89+
90+
def _delete_bindings(self, session):
91+
for name, _ in AUDIT_PERMISSIONS:
92+
session.execute(
93+
"""
94+
DELETE FROM iam_binding_permissions
95+
WHERE permission = %s;
96+
""",
97+
(_u(name),),
98+
)
99+
100+
def _delete_permissions(self, session):
101+
for name, _ in AUDIT_PERMISSIONS:
102+
session.execute(
103+
"""
104+
DELETE FROM iam_permissions
105+
WHERE uuid = %s;
106+
""",
107+
(_u(name),),
108+
)
109+
110+
def downgrade(self, session):
111+
self._delete_bindings(session)
112+
self._delete_permissions(session)
113+
migration_engine = self._get_sdk_migration_engine()
114+
migration_engine.rollback_migration(SDK_MIGRATION_FILE_NAME, session)
115+
116+
117+
migration_step = MigrationStep()

0 commit comments

Comments
 (0)