3tears-agent-audit 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 3tears_agent_audit-0.14.0/.gitignore +216 -0
- 3tears_agent_audit-0.14.0/LICENSE +21 -0
- 3tears_agent_audit-0.14.0/PKG-INFO +74 -0
- 3tears_agent_audit-0.14.0/README.md +51 -0
- 3tears_agent_audit-0.14.0/pyproject.toml +52 -0
- 3tears_agent_audit-0.14.0/src/threetears/agent/audit/__init__.py +38 -0
- 3tears_agent_audit-0.14.0/src/threetears/agent/audit/envelope.py +217 -0
- 3tears_agent_audit-0.14.0/src/threetears/agent/audit/publish.py +81 -0
- 3tears_agent_audit-0.14.0/src/threetears/agent/audit/py.typed +0 -0
- 3tears_agent_audit-0.14.0/tests/__init__.py +0 -0
- 3tears_agent_audit-0.14.0/tests/unit/__init__.py +0 -0
- 3tears_agent_audit-0.14.0/tests/unit/test_envelope.py +184 -0
- 3tears_agent_audit-0.14.0/tests/unit/test_publish.py +163 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
# Claude Code local state
|
|
210
|
+
.claude/
|
|
211
|
+
|
|
212
|
+
# prawduct session evidence (local governance artifacts, never shipped)
|
|
213
|
+
.prawduct/
|
|
214
|
+
|
|
215
|
+
# macOS folder metadata
|
|
216
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Pace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 3tears-agent-audit
|
|
3
|
+
Version: 0.14.0
|
|
4
|
+
Summary: unified audit envelope + fire-and-forget publish helper for the 3tears platform
|
|
5
|
+
Project-URL: Repository, https://github.com/pacepace/3tears
|
|
6
|
+
Author: pace
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: System :: Logging
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Requires-Dist: 3tears-nats
|
|
20
|
+
Requires-Dist: 3tears-observe
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# 3tears-agent-audit
|
|
25
|
+
|
|
26
|
+
Unified audit envelope + fire-and-forget publish helper for the 3tears platform.
|
|
27
|
+
|
|
28
|
+
## Purpose
|
|
29
|
+
|
|
30
|
+
Single `AuditEvent` envelope + single `publish_audit` helper used by every
|
|
31
|
+
domain (workspace, rbac, memory, custom tools) so the audit pipeline is
|
|
32
|
+
one subject tree, one consumer, one table, one admin query API. Replaces
|
|
33
|
+
domain-specific envelopes (`WorkspaceAuditEnvelope`,
|
|
34
|
+
`RbacAuditEnvelope`) that produced slightly-different wire shapes per domain
|
|
35
|
+
and made cross-domain audit queries require a UNION.
|
|
36
|
+
|
|
37
|
+
The package is pure Python with no NATS consumer code and no Postgres code.
|
|
38
|
+
Publish is the only direction: a consumer-side audit consumer owns
|
|
39
|
+
persistence to the audit events table.
|
|
40
|
+
|
|
41
|
+
## Public API
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from threetears.agent.audit import AuditEvent, publish_audit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `AuditEvent` -- pydantic `BaseModel` with `extra='forbid'`, timezone-aware
|
|
48
|
+
`timestamp` validator, closed `event_type` string family (dotted verb,
|
|
49
|
+
e.g. `workspace.fs_write`, `rbac.assignment.create`). All common identity
|
|
50
|
+
fields are typed columns on the envelope; event-type-specific extras live
|
|
51
|
+
in `details: dict[str, Any]`.
|
|
52
|
+
- `publish_audit(event, nats_client, namespace)` -- fire-and-forget async
|
|
53
|
+
helper. Serializes the envelope via `model_dump_json()` and awaits one
|
|
54
|
+
`nats_client.publish` on `{namespace}.audit.{event_type}`. On any publish
|
|
55
|
+
failure logs at WARN and returns; never raises.
|
|
56
|
+
|
|
57
|
+
## Design commitments
|
|
58
|
+
|
|
59
|
+
- **Fire-and-forget.** Audit publish failures must never break the producing
|
|
60
|
+
call. The helper catches every exception, logs at WARN, and returns.
|
|
61
|
+
- **Typed wire contract.** `extra='forbid'` + timezone-aware validator catch
|
|
62
|
+
publisher-side drift at construction time, not at the consumer's decode.
|
|
63
|
+
- **No domain-specific envelope types.** Every domain publishes the same
|
|
64
|
+
model; `event_type` conveys the domain.
|
|
65
|
+
- **No dual-emit.** There is no legacy envelope the consumer still accepts in
|
|
66
|
+
parallel. Emission sites migrate in the same PR that deletes the legacy
|
|
67
|
+
envelope module.
|
|
68
|
+
|
|
69
|
+
## Subject naming
|
|
70
|
+
|
|
71
|
+
`{namespace}.audit.{event_type}` where `event_type` is the dotted event
|
|
72
|
+
name (e.g. `{namespace}.audit.workspace.fs_write`). The consumer
|
|
73
|
+
subscribes to `{namespace}.audit.>` so new event types route automatically
|
|
74
|
+
without consumer-side changes.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 3tears-agent-audit
|
|
2
|
+
|
|
3
|
+
Unified audit envelope + fire-and-forget publish helper for the 3tears platform.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Single `AuditEvent` envelope + single `publish_audit` helper used by every
|
|
8
|
+
domain (workspace, rbac, memory, custom tools) so the audit pipeline is
|
|
9
|
+
one subject tree, one consumer, one table, one admin query API. Replaces
|
|
10
|
+
domain-specific envelopes (`WorkspaceAuditEnvelope`,
|
|
11
|
+
`RbacAuditEnvelope`) that produced slightly-different wire shapes per domain
|
|
12
|
+
and made cross-domain audit queries require a UNION.
|
|
13
|
+
|
|
14
|
+
The package is pure Python with no NATS consumer code and no Postgres code.
|
|
15
|
+
Publish is the only direction: a consumer-side audit consumer owns
|
|
16
|
+
persistence to the audit events table.
|
|
17
|
+
|
|
18
|
+
## Public API
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from threetears.agent.audit import AuditEvent, publish_audit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `AuditEvent` -- pydantic `BaseModel` with `extra='forbid'`, timezone-aware
|
|
25
|
+
`timestamp` validator, closed `event_type` string family (dotted verb,
|
|
26
|
+
e.g. `workspace.fs_write`, `rbac.assignment.create`). All common identity
|
|
27
|
+
fields are typed columns on the envelope; event-type-specific extras live
|
|
28
|
+
in `details: dict[str, Any]`.
|
|
29
|
+
- `publish_audit(event, nats_client, namespace)` -- fire-and-forget async
|
|
30
|
+
helper. Serializes the envelope via `model_dump_json()` and awaits one
|
|
31
|
+
`nats_client.publish` on `{namespace}.audit.{event_type}`. On any publish
|
|
32
|
+
failure logs at WARN and returns; never raises.
|
|
33
|
+
|
|
34
|
+
## Design commitments
|
|
35
|
+
|
|
36
|
+
- **Fire-and-forget.** Audit publish failures must never break the producing
|
|
37
|
+
call. The helper catches every exception, logs at WARN, and returns.
|
|
38
|
+
- **Typed wire contract.** `extra='forbid'` + timezone-aware validator catch
|
|
39
|
+
publisher-side drift at construction time, not at the consumer's decode.
|
|
40
|
+
- **No domain-specific envelope types.** Every domain publishes the same
|
|
41
|
+
model; `event_type` conveys the domain.
|
|
42
|
+
- **No dual-emit.** There is no legacy envelope the consumer still accepts in
|
|
43
|
+
parallel. Emission sites migrate in the same PR that deletes the legacy
|
|
44
|
+
envelope module.
|
|
45
|
+
|
|
46
|
+
## Subject naming
|
|
47
|
+
|
|
48
|
+
`{namespace}.audit.{event_type}` where `event_type` is the dotted event
|
|
49
|
+
name (e.g. `{namespace}.audit.workspace.fs_write`). The consumer
|
|
50
|
+
subscribes to `{namespace}.audit.>` so new event types route automatically
|
|
51
|
+
without consumer-side changes.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "3tears-agent-audit"
|
|
7
|
+
version = "0.14.0"
|
|
8
|
+
description = "unified audit envelope + fire-and-forget publish helper for the 3tears platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
authors = [{name = "pace"}]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: System :: Logging",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"3tears-nats",
|
|
27
|
+
"3tears-observe",
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/pacepace/3tears"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/threetears"]
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
3tears-nats = { workspace = true }
|
|
39
|
+
3tears-observe = { workspace = true }
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
strict = true
|
|
43
|
+
mypy_path = "src"
|
|
44
|
+
packages = ["threetears.agent.audit"]
|
|
45
|
+
explicit_package_bases = true
|
|
46
|
+
ignore_missing_imports = true
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
markers = [
|
|
51
|
+
"integration: integration tests requiring docker or real infra",
|
|
52
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""unified audit envelope + publish helper for the 3tears platform.
|
|
2
|
+
|
|
3
|
+
single :class:`AuditEvent` + :func:`publish_audit` shared by every
|
|
4
|
+
producing domain (workspace tools, hub rbac endpoints, agent memory,
|
|
5
|
+
custom tools). the hub-side ``unified_audit_consumer`` subscribes to
|
|
6
|
+
``{namespace}.audit.>`` and persists every event to
|
|
7
|
+
``platform_audit.audit_events`` without a domain-specific consumer.
|
|
8
|
+
|
|
9
|
+
produced by ``audit-task-01``; supersedes
|
|
10
|
+
:class:`threetears.agent.workspace.audit.WorkspaceAuditEnvelope` and
|
|
11
|
+
:class:`threetears.agent.acl.audit.RbacAuditEnvelope` (both deleted in
|
|
12
|
+
the same shard).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from threetears.agent.audit.envelope import AuditEvent
|
|
18
|
+
from threetears.agent.audit.publish import publish_audit
|
|
19
|
+
|
|
20
|
+
# Version derived from pyproject.toml so the metadata is the single
|
|
21
|
+
# source of truth -- a future release that bumps pyproject without
|
|
22
|
+
# updating ``__init__.py`` can't drift the runtime ``__version__``.
|
|
23
|
+
# The except guard handles the rare case where the package isn't
|
|
24
|
+
# installed via importlib.metadata (e.g. running directly from a
|
|
25
|
+
# checked-out source tree without ``uv sync``); the fallback keeps
|
|
26
|
+
# imports working but reports ``unknown`` rather than crashing.
|
|
27
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
28
|
+
from importlib.metadata import version as _version
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = _version("3tears-agent-audit")
|
|
32
|
+
except _PackageNotFoundError: # pragma: no cover - dev fallback
|
|
33
|
+
__version__ = "unknown"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AuditEvent",
|
|
37
|
+
"publish_audit",
|
|
38
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""unified audit envelope for every domain on the 3tears platform.
|
|
2
|
+
|
|
3
|
+
the pre-audit-task-01 world had per-domain envelope types
|
|
4
|
+
(:class:`WorkspaceAuditEnvelope`, :class:`RbacAuditEnvelope`, etc.)
|
|
5
|
+
each on a different NATS subject with a slightly different wire shape.
|
|
6
|
+
cross-domain audit queries paid for this with UNIONs across lightly-
|
|
7
|
+
different schemas and the admin query API had to special-case each
|
|
8
|
+
domain. audit-task-01 collapses all of it into one envelope, one
|
|
9
|
+
publish helper, one subject tree, one consumer, one table.
|
|
10
|
+
|
|
11
|
+
the envelope below is the single wire contract. every domain
|
|
12
|
+
(:mod:`threetears.agent.workspace.tools.*`, hub rbac endpoints, agent
|
|
13
|
+
memory, user tools) builds an :class:`AuditEvent` and hands it to
|
|
14
|
+
:func:`publish_audit`. the hub-side ``unified_audit_consumer``
|
|
15
|
+
subscribes to ``{namespace}.audit.>`` and persists to
|
|
16
|
+
``platform_audit.audit_events``.
|
|
17
|
+
|
|
18
|
+
anti-drift guarantees:
|
|
19
|
+
|
|
20
|
+
- ``model_config`` uses ``extra='forbid'`` -- any stray field on the
|
|
21
|
+
wire fails parse loudly. a miswired publisher surfaces at construct
|
|
22
|
+
time rather than silently dropping the field.
|
|
23
|
+
- ``timestamp`` must be timezone-aware UTC; a validator rejects naive
|
|
24
|
+
datetimes with a clear message.
|
|
25
|
+
- ``event_type`` uses the dotted ``{domain}.{verb}[.{sub_verb}]``
|
|
26
|
+
convention (e.g. ``workspace.fs_write``, ``rbac.assignment.create``,
|
|
27
|
+
``rbac.introspect.explain``). it is an open ``str`` rather than a
|
|
28
|
+
closed ``Literal``: the envelope intentionally does not gate new
|
|
29
|
+
event types at the wire layer because new domains (memory, custom
|
|
30
|
+
tools) must be able to publish without editing this module. per-
|
|
31
|
+
domain closed enums belong at the producer site, not on the shared
|
|
32
|
+
envelope.
|
|
33
|
+
- ``details: dict[str, Any]`` is the escape hatch for event-type-
|
|
34
|
+
specific extras. commonly-queried fields (actor, resource, action,
|
|
35
|
+
outcome) are typed columns so admin queries don't JSONB-parse on
|
|
36
|
+
every row.
|
|
37
|
+
|
|
38
|
+
identity axes on the envelope:
|
|
39
|
+
|
|
40
|
+
- ``actor_user_id`` -- the human (or service account) whose action
|
|
41
|
+
triggered the event; the load-bearing "who did this?" field.
|
|
42
|
+
- ``calling_agent_id`` -- the agent whose pod ran the tool. equal to
|
|
43
|
+
``owner_agent_id`` for owner-path calls; differs under cross-agent
|
|
44
|
+
sharing.
|
|
45
|
+
- ``owner_agent_id`` -- the agent that owns the resource being
|
|
46
|
+
touched. sourced from the resource's namespace row.
|
|
47
|
+
- ``customer_id`` -- the owning customer. ``None`` only for
|
|
48
|
+
platform-scoped events (e.g. platform role CRUD).
|
|
49
|
+
- ``resource_namespace_id`` -- the resource's namespace row id. every
|
|
50
|
+
resource on the platform is a namespace post-workspace-task-19, so
|
|
51
|
+
one column answers "whose data?" across every domain.
|
|
52
|
+
- ``resource_namespace_type`` -- short taxonomy tag (``workspace`` /
|
|
53
|
+
``group`` / ``role`` / ``assignment`` / ``introspection`` / ...).
|
|
54
|
+
matches the ``namespaces.namespace_type`` taxonomy.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from datetime import datetime
|
|
60
|
+
from typing import Any
|
|
61
|
+
from uuid import UUID
|
|
62
|
+
|
|
63
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
64
|
+
|
|
65
|
+
__all__ = ["AuditEvent"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AuditEvent(BaseModel):
|
|
69
|
+
"""typed wire-format envelope for every platform audit event.
|
|
70
|
+
|
|
71
|
+
single canonical envelope shared by every producing domain and
|
|
72
|
+
the hub-side ``unified_audit_consumer``. built on the producer
|
|
73
|
+
side (workspace tool, rbac endpoint, memory subsystem, custom
|
|
74
|
+
tool) and handed to :func:`publish_audit`. decoded on the hub
|
|
75
|
+
side from ``{namespace}.audit.>`` by the unified consumer.
|
|
76
|
+
|
|
77
|
+
:param id: envelope identity (uuid7 time-ordered); doubles as the
|
|
78
|
+
audit row primary key when the consumer persists it
|
|
79
|
+
:ptype id: UUID
|
|
80
|
+
:param timestamp: timezone-aware UTC instant the event occurred
|
|
81
|
+
:ptype timestamp: datetime
|
|
82
|
+
:param event_type: dotted event family, e.g. ``workspace.fs_write``,
|
|
83
|
+
``rbac.assignment.create``, ``rbac.introspect.explain``. used
|
|
84
|
+
as the last segment of the NATS subject and as the primary
|
|
85
|
+
``event_type`` column on ``audit_events``
|
|
86
|
+
:ptype event_type: str
|
|
87
|
+
:param actor_user_id: human / service-account actor; ``None`` for
|
|
88
|
+
system-triggered events (e.g. scheduled retention)
|
|
89
|
+
:ptype actor_user_id: UUID | None
|
|
90
|
+
:param calling_agent_id: agent whose pod ran the tool; ``None`` for
|
|
91
|
+
events emitted from the hub directly (e.g. rbac admin endpoints)
|
|
92
|
+
:ptype calling_agent_id: UUID | None
|
|
93
|
+
:param owner_agent_id: agent that owns the resource being touched;
|
|
94
|
+
``None`` for platform-scoped events
|
|
95
|
+
:ptype owner_agent_id: UUID | None
|
|
96
|
+
:param customer_id: owning customer; ``None`` only for platform-
|
|
97
|
+
scoped events (platform role CRUD, platform-scoped groups)
|
|
98
|
+
:ptype customer_id: UUID | None
|
|
99
|
+
:param resource_namespace_id: namespace row id of the resource;
|
|
100
|
+
``None`` for events whose subject is not a single namespace
|
|
101
|
+
(introspection queries, cross-resource dry-runs)
|
|
102
|
+
:ptype resource_namespace_id: UUID | None
|
|
103
|
+
:param resource_namespace_type: short taxonomy tag matching
|
|
104
|
+
``namespaces.namespace_type`` (``workspace`` / ``group`` /
|
|
105
|
+
``role`` / ``assignment`` / ``introspection`` / ...). ``None``
|
|
106
|
+
when ``resource_namespace_id`` is ``None``
|
|
107
|
+
:ptype resource_namespace_type: str | None
|
|
108
|
+
:param action: short verb (``read`` / ``write`` / ``create`` /
|
|
109
|
+
``update`` / ``delete`` / ``add_member`` / ``remove_member`` /
|
|
110
|
+
``explain`` / ...). kept open-set so new domains don't edit
|
|
111
|
+
the envelope module
|
|
112
|
+
:ptype action: str
|
|
113
|
+
:param outcome: one of ``success``, ``failure``, ``error``. tool-
|
|
114
|
+
dispatch auto-emission stamps this based on the handler return
|
|
115
|
+
path; explicit emissions pass ``success`` by convention unless
|
|
116
|
+
they know otherwise
|
|
117
|
+
:ptype outcome: str
|
|
118
|
+
:param correlation_id: request / tool-call correlation UUID; used
|
|
119
|
+
with ``event_type`` as the idempotency key for at-least-once
|
|
120
|
+
NATS redelivery
|
|
121
|
+
:ptype correlation_id: UUID
|
|
122
|
+
:param conversation_id: conversation UUID when the event was
|
|
123
|
+
emitted on behalf of a user-facing conversation (agent-tools
|
|
124
|
+
baseline emission, memory writes, context save). ``None`` for
|
|
125
|
+
platform-scoped events (rbac admin endpoints, scheduled
|
|
126
|
+
retention) and for hub-direct calls without a conversation.
|
|
127
|
+
added in data-layer-task-01 sub-task 5: the ``audit_events``
|
|
128
|
+
table already carries the column (added in v054 partition
|
|
129
|
+
primitive); the envelope now propagates it from
|
|
130
|
+
:class:`CallContext` into the persisted row so admin queries
|
|
131
|
+
can filter audit trails per conversation
|
|
132
|
+
:ptype conversation_id: UUID | None
|
|
133
|
+
:param details: event-type-specific structured payload (sha/bytes/
|
|
134
|
+
version for fs_write; added_member_id for group.member.add;
|
|
135
|
+
etc.). admin queries must not depend on JSONB fields for hot
|
|
136
|
+
paths
|
|
137
|
+
:ptype details: dict[str, Any]
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
model_config = ConfigDict(extra="forbid")
|
|
141
|
+
|
|
142
|
+
id: UUID
|
|
143
|
+
timestamp: datetime
|
|
144
|
+
event_type: str
|
|
145
|
+
actor_user_id: UUID | None = None
|
|
146
|
+
calling_agent_id: UUID | None = None
|
|
147
|
+
owner_agent_id: UUID | None = None
|
|
148
|
+
customer_id: UUID | None = None
|
|
149
|
+
resource_namespace_id: UUID | None = None
|
|
150
|
+
resource_namespace_type: str | None = None
|
|
151
|
+
action: str
|
|
152
|
+
outcome: str = "success"
|
|
153
|
+
correlation_id: UUID
|
|
154
|
+
conversation_id: UUID | None = None
|
|
155
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
@field_validator("timestamp")
|
|
158
|
+
@classmethod
|
|
159
|
+
def _require_timezone_aware(cls, value: datetime) -> datetime:
|
|
160
|
+
"""reject naive datetimes -- every audit envelope is UTC-aware.
|
|
161
|
+
|
|
162
|
+
:param value: parsed datetime
|
|
163
|
+
:ptype value: datetime
|
|
164
|
+
:return: the same datetime when it carries a tzinfo
|
|
165
|
+
:rtype: datetime
|
|
166
|
+
:raises ValueError: when the input is naive
|
|
167
|
+
"""
|
|
168
|
+
if value.tzinfo is None:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"AuditEvent.timestamp must be timezone-aware (UTC); received a naive datetime",
|
|
171
|
+
)
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
@field_validator("outcome")
|
|
175
|
+
@classmethod
|
|
176
|
+
def _require_known_outcome(cls, value: str) -> str:
|
|
177
|
+
"""restrict ``outcome`` to the three recognised values.
|
|
178
|
+
|
|
179
|
+
the closed set keeps the admin query API's outcome filter
|
|
180
|
+
well-defined without forcing a ``Literal[...]`` on the whole
|
|
181
|
+
envelope (which would break dict-based construction from YAML
|
|
182
|
+
test fixtures).
|
|
183
|
+
|
|
184
|
+
:param value: candidate outcome string
|
|
185
|
+
:ptype value: str
|
|
186
|
+
:return: the input value unchanged when valid
|
|
187
|
+
:rtype: str
|
|
188
|
+
:raises ValueError: when ``value`` is not success/failure/error
|
|
189
|
+
"""
|
|
190
|
+
allowed = {"success", "failure", "error"}
|
|
191
|
+
if value not in allowed:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"AuditEvent.outcome must be one of {sorted(allowed)}; received {value!r}",
|
|
194
|
+
)
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
@field_validator("event_type")
|
|
198
|
+
@classmethod
|
|
199
|
+
def _require_dotted_event_type(cls, value: str) -> str:
|
|
200
|
+
"""require the ``{domain}.{verb}`` dotted convention.
|
|
201
|
+
|
|
202
|
+
the envelope does not gate the set of event types (new domains
|
|
203
|
+
must be able to publish without editing this module) but it
|
|
204
|
+
does require the dotted shape so consumers can parse the
|
|
205
|
+
leading domain segment reliably.
|
|
206
|
+
|
|
207
|
+
:param value: candidate event_type
|
|
208
|
+
:ptype value: str
|
|
209
|
+
:return: the input value unchanged when it matches the shape
|
|
210
|
+
:rtype: str
|
|
211
|
+
:raises ValueError: when the value is empty or lacks a dot
|
|
212
|
+
"""
|
|
213
|
+
if not value or "." not in value:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"AuditEvent.event_type must be dotted '{{domain}}.{{verb}}[.{{sub_verb}}]'; received {value!r}",
|
|
216
|
+
)
|
|
217
|
+
return value
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""fire-and-forget audit publish helper.
|
|
2
|
+
|
|
3
|
+
:func:`publish_audit` is the single publish path for every domain.
|
|
4
|
+
serializes the :class:`AuditEvent` via pydantic, awaits one
|
|
5
|
+
:meth:`NatsClient.publish` on ``{namespace}.audit.{event_type}``, and
|
|
6
|
+
swallows every exception at WARN. tool-call success must never depend
|
|
7
|
+
on audit infrastructure availability; this invariant is load-bearing.
|
|
8
|
+
|
|
9
|
+
the hub-side ``unified_audit_consumer`` subscribes to
|
|
10
|
+
``{namespace}.audit.>`` so new event types route automatically without
|
|
11
|
+
a consumer-side change.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from threetears.nats import NatsClient, Subject
|
|
17
|
+
from threetears.observe import get_logger
|
|
18
|
+
|
|
19
|
+
from threetears.agent.audit.envelope import AuditEvent
|
|
20
|
+
|
|
21
|
+
__all__ = ["publish_audit"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def publish_audit(
|
|
28
|
+
event: AuditEvent,
|
|
29
|
+
*,
|
|
30
|
+
nats_client: NatsClient | None,
|
|
31
|
+
namespace: str,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
publish one audit envelope on ``{namespace}.audit.{event_type}``.
|
|
35
|
+
|
|
36
|
+
fire-and-forget: any exception during publish is caught and logged
|
|
37
|
+
at WARN; no exception propagates to the caller. when
|
|
38
|
+
``nats_client`` is ``None`` the call is an explicit no-op (useful
|
|
39
|
+
in tests and bootstrap windows before NATS wiring is complete).
|
|
40
|
+
|
|
41
|
+
the subject is built with the explicit ``namespace`` argument
|
|
42
|
+
rather than reading the :class:`Subjects` ContextVar so callers
|
|
43
|
+
that route audit traffic on a per-call namespace (multi-tenant
|
|
44
|
+
test fixtures, in-process audit consumers under explicit prefix
|
|
45
|
+
control) get the namespace they passed regardless of which
|
|
46
|
+
ContextVar value happens to be bound on the calling task.
|
|
47
|
+
``event.event_type`` carries dots verbatim (e.g.
|
|
48
|
+
``workspace.fs_write``); they form the subject hierarchy and are
|
|
49
|
+
NOT sanitized.
|
|
50
|
+
|
|
51
|
+
:param event: typed audit envelope to publish
|
|
52
|
+
:ptype event: AuditEvent
|
|
53
|
+
:param nats_client: connected canonical
|
|
54
|
+
:class:`threetears.nats.NatsClient` wrapper; ``None`` is a no-op
|
|
55
|
+
:ptype nats_client: NatsClient | None
|
|
56
|
+
:param namespace: NATS subject namespace (environment-scoped
|
|
57
|
+
prefix from ``THREETEARS_NATS_SUBJECT_NAMESPACE``)
|
|
58
|
+
:ptype namespace: str
|
|
59
|
+
:return: nothing
|
|
60
|
+
:rtype: None
|
|
61
|
+
"""
|
|
62
|
+
if nats_client is None:
|
|
63
|
+
# bootstrap / test scenario; explicit no-op
|
|
64
|
+
return
|
|
65
|
+
subject = Subject.raw(f"{namespace}.audit.{event.event_type}")
|
|
66
|
+
try:
|
|
67
|
+
await nats_client.publish(subject=subject, message=event)
|
|
68
|
+
# NOSILENT: audit publish is fire-and-forget; failures log at WARN
|
|
69
|
+
# so the producing call path is never blocked by audit health.
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
log.warning(
|
|
72
|
+
"audit publish failed",
|
|
73
|
+
extra={
|
|
74
|
+
"extra_data": {
|
|
75
|
+
"subject": subject.path,
|
|
76
|
+
"event_type": event.event_type,
|
|
77
|
+
"namespace": namespace,
|
|
78
|
+
"error": str(exc),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""unit tests for :class:`threetears.agent.audit.AuditEvent`.
|
|
2
|
+
|
|
3
|
+
covers the envelope's anti-drift guarantees (``extra='forbid'``,
|
|
4
|
+
timezone-aware ``timestamp``, closed ``outcome``, dotted
|
|
5
|
+
``event_type``) and round-trip ``model_dump_json`` /
|
|
6
|
+
``model_validate_json`` semantics used by the NATS publisher +
|
|
7
|
+
consumer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from uuid import UUID, uuid7
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from threetears.agent.audit import AuditEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _base_kwargs() -> dict[str, object]:
|
|
22
|
+
"""
|
|
23
|
+
build a baseline valid kwargs dict for :class:`AuditEvent`.
|
|
24
|
+
|
|
25
|
+
:return: kwargs dict populated with a consistent set of field
|
|
26
|
+
values; individual tests mutate one key to exercise a
|
|
27
|
+
specific validator
|
|
28
|
+
:rtype: dict[str, object]
|
|
29
|
+
"""
|
|
30
|
+
return {
|
|
31
|
+
"id": uuid7(),
|
|
32
|
+
"timestamp": datetime.now(UTC),
|
|
33
|
+
"event_type": "workspace.fs_write",
|
|
34
|
+
"actor_user_id": uuid7(),
|
|
35
|
+
"calling_agent_id": uuid7(),
|
|
36
|
+
"owner_agent_id": uuid7(),
|
|
37
|
+
"customer_id": uuid7(),
|
|
38
|
+
"resource_namespace_id": uuid7(),
|
|
39
|
+
"resource_namespace_type": "workspace",
|
|
40
|
+
"action": "write",
|
|
41
|
+
"outcome": "success",
|
|
42
|
+
"correlation_id": uuid7(),
|
|
43
|
+
"details": {"sha256": "abc123", "bytes": 42},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_constructs_with_all_required_fields() -> None:
|
|
48
|
+
"""envelope builds cleanly when every field is supplied."""
|
|
49
|
+
envelope = AuditEvent(**_base_kwargs()) # type: ignore[arg-type]
|
|
50
|
+
|
|
51
|
+
assert envelope.event_type == "workspace.fs_write"
|
|
52
|
+
assert envelope.action == "write"
|
|
53
|
+
assert envelope.outcome == "success"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_optional_identity_fields_default_none() -> None:
|
|
57
|
+
"""envelope builds with only the required scalar axes populated."""
|
|
58
|
+
envelope = AuditEvent(
|
|
59
|
+
id=uuid7(),
|
|
60
|
+
timestamp=datetime.now(UTC),
|
|
61
|
+
event_type="platform.role.create",
|
|
62
|
+
action="create",
|
|
63
|
+
correlation_id=uuid7(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
assert envelope.actor_user_id is None
|
|
67
|
+
assert envelope.calling_agent_id is None
|
|
68
|
+
assert envelope.owner_agent_id is None
|
|
69
|
+
assert envelope.customer_id is None
|
|
70
|
+
assert envelope.resource_namespace_id is None
|
|
71
|
+
assert envelope.resource_namespace_type is None
|
|
72
|
+
assert envelope.conversation_id is None
|
|
73
|
+
assert envelope.outcome == "success"
|
|
74
|
+
assert envelope.details == {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_conversation_id_field_round_trips() -> None:
|
|
78
|
+
"""data-layer-task-01 sub-task 5: conversation_id propagates.
|
|
79
|
+
|
|
80
|
+
the ``audit_events`` table already carries the column; the
|
|
81
|
+
envelope now propagates it from CallContext into the persisted
|
|
82
|
+
row so admin queries can filter audit trails per conversation.
|
|
83
|
+
"""
|
|
84
|
+
conv_id = uuid7()
|
|
85
|
+
envelope = AuditEvent(
|
|
86
|
+
id=uuid7(),
|
|
87
|
+
timestamp=datetime.now(UTC),
|
|
88
|
+
event_type="tool.call",
|
|
89
|
+
action="call",
|
|
90
|
+
correlation_id=uuid7(),
|
|
91
|
+
conversation_id=conv_id,
|
|
92
|
+
)
|
|
93
|
+
assert envelope.conversation_id == conv_id
|
|
94
|
+
wire = envelope.model_dump_json()
|
|
95
|
+
decoded = AuditEvent.model_validate_json(wire)
|
|
96
|
+
assert decoded.conversation_id == conv_id
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_naive_timestamp_rejected() -> None:
|
|
100
|
+
"""``timestamp`` must carry a tzinfo; a naive datetime raises."""
|
|
101
|
+
kwargs = _base_kwargs()
|
|
102
|
+
kwargs["timestamp"] = datetime(2026, 1, 1, 12, 0, 0)
|
|
103
|
+
|
|
104
|
+
with pytest.raises(ValidationError):
|
|
105
|
+
AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_unknown_outcome_rejected() -> None:
|
|
109
|
+
"""``outcome`` is closed to success/failure/error."""
|
|
110
|
+
kwargs = _base_kwargs()
|
|
111
|
+
kwargs["outcome"] = "partial"
|
|
112
|
+
|
|
113
|
+
with pytest.raises(ValidationError):
|
|
114
|
+
AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize("outcome", ["success", "failure", "error"])
|
|
118
|
+
def test_every_declared_outcome_accepted(outcome: str) -> None:
|
|
119
|
+
"""each recognised outcome string constructs the envelope."""
|
|
120
|
+
kwargs = _base_kwargs()
|
|
121
|
+
kwargs["outcome"] = outcome
|
|
122
|
+
|
|
123
|
+
envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
124
|
+
|
|
125
|
+
assert envelope.outcome == outcome
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_non_dotted_event_type_rejected() -> None:
|
|
129
|
+
"""``event_type`` must be dotted ``{domain}.{verb}``."""
|
|
130
|
+
kwargs = _base_kwargs()
|
|
131
|
+
kwargs["event_type"] = "justaword"
|
|
132
|
+
|
|
133
|
+
with pytest.raises(ValidationError):
|
|
134
|
+
AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_empty_event_type_rejected() -> None:
|
|
138
|
+
"""an empty ``event_type`` string is rejected."""
|
|
139
|
+
kwargs = _base_kwargs()
|
|
140
|
+
kwargs["event_type"] = ""
|
|
141
|
+
|
|
142
|
+
with pytest.raises(ValidationError):
|
|
143
|
+
AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_extra_fields_rejected() -> None:
|
|
147
|
+
"""``extra='forbid'`` rejects any field not declared on the envelope."""
|
|
148
|
+
kwargs = _base_kwargs()
|
|
149
|
+
kwargs["mystery_field"] = "oops"
|
|
150
|
+
|
|
151
|
+
with pytest.raises(ValidationError):
|
|
152
|
+
AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_round_trip_preserves_every_field() -> None:
|
|
156
|
+
"""JSON round trip preserves UUID, datetime, and details shape."""
|
|
157
|
+
kwargs = _base_kwargs()
|
|
158
|
+
envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
159
|
+
|
|
160
|
+
wire = envelope.model_dump_json()
|
|
161
|
+
decoded = AuditEvent.model_validate_json(wire)
|
|
162
|
+
|
|
163
|
+
assert decoded == envelope
|
|
164
|
+
assert decoded.details == envelope.details
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_details_defaults_to_empty_dict() -> None:
|
|
168
|
+
"""omitting ``details`` yields an empty dict (not None)."""
|
|
169
|
+
kwargs = _base_kwargs()
|
|
170
|
+
del kwargs["details"]
|
|
171
|
+
|
|
172
|
+
envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
173
|
+
|
|
174
|
+
assert envelope.details == {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_id_round_trips_as_uuid() -> None:
|
|
178
|
+
"""``id`` is a UUID object, not a string, after JSON round trip."""
|
|
179
|
+
kwargs = _base_kwargs()
|
|
180
|
+
envelope = AuditEvent(**kwargs) # type: ignore[arg-type]
|
|
181
|
+
decoded = AuditEvent.model_validate_json(envelope.model_dump_json())
|
|
182
|
+
|
|
183
|
+
assert isinstance(decoded.id, UUID)
|
|
184
|
+
assert decoded.id == envelope.id
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""unit tests for :func:`threetears.agent.audit.publish_audit`.
|
|
2
|
+
|
|
3
|
+
covers subject-naming, payload-shape, and the fire-and-forget
|
|
4
|
+
invariant: every publish failure logs at WARN and returns cleanly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from uuid import uuid7
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from threetears.agent.audit import AuditEvent, publish_audit
|
|
17
|
+
from threetears.nats import Subject, set_default_namespace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _FakeWrapper:
|
|
22
|
+
"""minimal fake :class:`threetears.nats.NatsClient` recording each publish.
|
|
23
|
+
|
|
24
|
+
matches the wrapper's :meth:`publish` shape (kw-only ``subject`` +
|
|
25
|
+
``message``) so tests exercise the same call surface production
|
|
26
|
+
code uses. payload bytes are derived from the recorded
|
|
27
|
+
:class:`BaseModel` to keep round-trip assertions intact.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
publish_calls: list[tuple[Subject, BaseModel]] = field(default_factory=list)
|
|
31
|
+
raise_on_publish: BaseException | None = None
|
|
32
|
+
|
|
33
|
+
async def publish(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
subject: Subject,
|
|
37
|
+
message: BaseModel,
|
|
38
|
+
reply_to: Subject | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""record the publish invocation or raise when configured to."""
|
|
41
|
+
del reply_to # unused in audit publish
|
|
42
|
+
self.publish_calls.append((subject, message))
|
|
43
|
+
if self.raise_on_publish is not None:
|
|
44
|
+
raise self.raise_on_publish
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(autouse=True)
|
|
48
|
+
def _bind_namespace(request: pytest.FixtureRequest) -> None:
|
|
49
|
+
"""bind a known default namespace for each test.
|
|
50
|
+
|
|
51
|
+
individual tests override via the ``namespace`` parameter on
|
|
52
|
+
:func:`publish_audit` (passed through for diagnostic logging) and
|
|
53
|
+
by calling :func:`set_default_namespace` directly to control the
|
|
54
|
+
subject that :class:`Subjects.audit_event` produces.
|
|
55
|
+
"""
|
|
56
|
+
set_default_namespace("3tears")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_event(event_type: str = "workspace.fs_write") -> AuditEvent:
|
|
60
|
+
"""build a valid envelope for the publish-helper tests."""
|
|
61
|
+
return AuditEvent(
|
|
62
|
+
id=uuid7(),
|
|
63
|
+
timestamp=datetime.now(UTC),
|
|
64
|
+
event_type=event_type,
|
|
65
|
+
actor_user_id=uuid7(),
|
|
66
|
+
calling_agent_id=uuid7(),
|
|
67
|
+
owner_agent_id=uuid7(),
|
|
68
|
+
customer_id=uuid7(),
|
|
69
|
+
resource_namespace_id=uuid7(),
|
|
70
|
+
resource_namespace_type="workspace",
|
|
71
|
+
action="write",
|
|
72
|
+
correlation_id=uuid7(),
|
|
73
|
+
details={"bytes": 42},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_publish_posts_to_namespace_dot_audit_dot_event_type() -> None:
|
|
78
|
+
"""subject is ``{namespace}.audit.{event_type}`` verbatim."""
|
|
79
|
+
nats = _FakeWrapper()
|
|
80
|
+
event = _build_event("workspace.fs_write")
|
|
81
|
+
|
|
82
|
+
await publish_audit(event, nats_client=nats, namespace="dev")
|
|
83
|
+
|
|
84
|
+
assert len(nats.publish_calls) == 1
|
|
85
|
+
subject, _ = nats.publish_calls[0]
|
|
86
|
+
assert subject.path == "dev.audit.workspace.fs_write"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_publish_emits_typed_audit_event() -> None:
|
|
90
|
+
"""recorded message is the same :class:`AuditEvent` instance round-trippable to JSON."""
|
|
91
|
+
nats = _FakeWrapper()
|
|
92
|
+
event = _build_event("rbac.assignment.create")
|
|
93
|
+
|
|
94
|
+
await publish_audit(event, nats_client=nats, namespace="staging")
|
|
95
|
+
|
|
96
|
+
_, message = nats.publish_calls[0]
|
|
97
|
+
assert isinstance(message, AuditEvent)
|
|
98
|
+
assert message == event
|
|
99
|
+
# round-trip the wire form to confirm the typed payload survives serialization
|
|
100
|
+
decoded = AuditEvent.model_validate_json(message.model_dump_json())
|
|
101
|
+
assert decoded == event
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_publish_failure_is_swallowed_not_raised() -> None:
|
|
105
|
+
"""publish failure must NOT propagate -- audit is fire-and-forget."""
|
|
106
|
+
nats = _FakeWrapper(raise_on_publish=RuntimeError("nats down"))
|
|
107
|
+
event = _build_event()
|
|
108
|
+
|
|
109
|
+
# must NOT raise; tool-call success never depends on audit health
|
|
110
|
+
await publish_audit(event, nats_client=nats, namespace="dev")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_publish_none_client_is_noop() -> None:
|
|
114
|
+
"""``nats_client=None`` is an explicit no-op (bootstrap / tests)."""
|
|
115
|
+
event = _build_event()
|
|
116
|
+
|
|
117
|
+
# must NOT raise; no-op
|
|
118
|
+
await publish_audit(event, nats_client=None, namespace="dev")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize(
|
|
122
|
+
"event_type",
|
|
123
|
+
[
|
|
124
|
+
"workspace.fs_write",
|
|
125
|
+
"workspace.fs_edit",
|
|
126
|
+
"rbac.group.create",
|
|
127
|
+
"rbac.assignment.delete",
|
|
128
|
+
"rbac.introspect.explain",
|
|
129
|
+
"memory.retrieve",
|
|
130
|
+
"custom.tool.call",
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
async def test_publish_preserves_dotted_event_type_in_subject(
|
|
134
|
+
event_type: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""every dotted event_type appears verbatim in the subject."""
|
|
137
|
+
nats = _FakeWrapper()
|
|
138
|
+
event = _build_event(event_type)
|
|
139
|
+
|
|
140
|
+
await publish_audit(event, nats_client=nats, namespace="prod")
|
|
141
|
+
|
|
142
|
+
subject, _ = nats.publish_calls[0]
|
|
143
|
+
assert subject.path == f"prod.audit.{event_type}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def test_publish_serialization_error_does_not_raise() -> None:
|
|
147
|
+
"""a mid-publish exception is caught and logged."""
|
|
148
|
+
|
|
149
|
+
class _BrokenWrapper:
|
|
150
|
+
async def publish(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
subject: Subject,
|
|
154
|
+
message: BaseModel,
|
|
155
|
+
reply_to: Subject | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
del subject, message, reply_to
|
|
158
|
+
raise TimeoutError("publish timeout")
|
|
159
|
+
|
|
160
|
+
event = _build_event()
|
|
161
|
+
|
|
162
|
+
# must NOT raise
|
|
163
|
+
await publish_audit(event, nats_client=_BrokenWrapper(), namespace="dev")
|