terp-cap-audit 0.1.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.
- terp_cap_audit-0.1.0/.gitignore +47 -0
- terp_cap_audit-0.1.0/PKG-INFO +7 -0
- terp_cap_audit-0.1.0/escape-hatch-budget.json +4 -0
- terp_cap_audit-0.1.0/pyproject.toml +30 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/__init__.py +31 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/migrations/versions/3a9d71c5e40f_enforce_audit_append_only.py +97 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/migrations/versions/586333320cdd_create_audit_tables.py +59 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/models.py +54 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/py.typed +0 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/router.py +37 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/schemas.py +23 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/service.py +32 -0
- terp_cap_audit-0.1.0/src/terp/capabilities/audit/sink.py +51 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp audit capability — the durable, append-only sink for the core audit seam."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# Self-registering: the kernel discovers this ModuleSpec via the entry point,
|
|
16
|
+
# mounting the admin (read-only) `audit` log router without any composition-root
|
|
17
|
+
# edit. The durable sink is installed by create_app(audit_sink=persist_audit).
|
|
18
|
+
[project.entry-points."terp.capabilities"]
|
|
19
|
+
audit = "terp.capabilities.audit:module"
|
|
20
|
+
|
|
21
|
+
# Owns the append-only `audit_event` table, so it ships an independent, linear
|
|
22
|
+
# Alembic history (its own `alembic_version_audit` table). `terp migrate` discovers
|
|
23
|
+
# this via the `terp.migrations` group (ADR 0027).
|
|
24
|
+
[project.entry-points."terp.migrations"]
|
|
25
|
+
audit = "terp.capabilities.audit"
|
|
26
|
+
|
|
27
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.audit`.
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
sources = ["src"]
|
|
30
|
+
only-include = ["src/terp/capabilities/audit"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""terp.capabilities.audit — the durable sink behind the core audit seam.
|
|
2
|
+
|
|
3
|
+
``terp.core`` defines the audit *seam* (a typed :class:`~terp.core.AuditRecord`, the
|
|
4
|
+
:class:`~terp.core.AuditPolicy` registry, and an emit chokepoint whose default sink
|
|
5
|
+
only logs). This opt-in capability supplies the **durable** half: an append-only
|
|
6
|
+
:class:`AuditEvent` table, the :func:`persist_audit` sink, and a self-registering,
|
|
7
|
+
admin-only router to read the trail.
|
|
8
|
+
|
|
9
|
+
Wiring is a single composition-root line — ``create_app(...,
|
|
10
|
+
audit_sink=persist_audit)`` — after which **every** ``BaseService`` mutation in
|
|
11
|
+
every module is recorded with zero per-module code. It depends only on
|
|
12
|
+
``terp-core``: the sink consumes the public :class:`~terp.core.AuditRecord` /
|
|
13
|
+
:class:`~terp.core.AuditPolicy` surface, never a sibling capability.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from terp.capabilities.audit.models import AuditEvent
|
|
19
|
+
from terp.capabilities.audit.router import module, router
|
|
20
|
+
from terp.capabilities.audit.schemas import AuditEventRead
|
|
21
|
+
from terp.capabilities.audit.service import list_audit_events
|
|
22
|
+
from terp.capabilities.audit.sink import persist_audit
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AuditEvent",
|
|
26
|
+
"AuditEventRead",
|
|
27
|
+
"list_audit_events",
|
|
28
|
+
"module",
|
|
29
|
+
"persist_audit",
|
|
30
|
+
"router",
|
|
31
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""enforce audit append-only at the database
|
|
2
|
+
|
|
3
|
+
Revision ID: 3a9d71c5e40f
|
|
4
|
+
Revises: 586333320cdd
|
|
5
|
+
Create Date: 2026-07-06 20:45:00.000000
|
|
6
|
+
|
|
7
|
+
ADR 0076: the audit trail was append-only **by convention** (no service exposes
|
|
8
|
+
an update/delete); this revision makes the database itself refuse an UPDATE or
|
|
9
|
+
DELETE on ``audit_event``, so even code that bypasses the service layer (a raw
|
|
10
|
+
session, a compromised dependency, an ad-hoc script on the app role) cannot
|
|
11
|
+
silently rewrite history. Row-level triggers abort both statements on the two
|
|
12
|
+
dialects the gate exercises (SQLite dev/test, PostgreSQL production).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from alembic import op
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# revision identifiers, used by Alembic.
|
|
22
|
+
revision: str = '3a9d71c5e40f'
|
|
23
|
+
down_revision: str | None = '586333320cdd'
|
|
24
|
+
branch_labels: str | Sequence[str] | None = None
|
|
25
|
+
depends_on: str | Sequence[str] | None = None
|
|
26
|
+
|
|
27
|
+
_POSTGRES_UPGRADE = """
|
|
28
|
+
CREATE FUNCTION terp_audit_event_append_only() RETURNS trigger AS $$
|
|
29
|
+
BEGIN
|
|
30
|
+
RAISE EXCEPTION 'audit_event is append-only (ADR 0076)';
|
|
31
|
+
END;
|
|
32
|
+
$$ LANGUAGE plpgsql;
|
|
33
|
+
|
|
34
|
+
CREATE TRIGGER trg_audit_event_no_update
|
|
35
|
+
BEFORE UPDATE ON audit_event
|
|
36
|
+
FOR EACH ROW EXECUTE FUNCTION terp_audit_event_append_only();
|
|
37
|
+
|
|
38
|
+
CREATE TRIGGER trg_audit_event_no_delete
|
|
39
|
+
BEFORE DELETE ON audit_event
|
|
40
|
+
FOR EACH ROW EXECUTE FUNCTION terp_audit_event_append_only();
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_POSTGRES_DOWNGRADE = """
|
|
44
|
+
DROP TRIGGER trg_audit_event_no_delete ON audit_event;
|
|
45
|
+
|
|
46
|
+
DROP TRIGGER trg_audit_event_no_update ON audit_event;
|
|
47
|
+
|
|
48
|
+
DROP FUNCTION terp_audit_event_append_only();
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_SQLITE_UPGRADE = """
|
|
52
|
+
CREATE TRIGGER trg_audit_event_no_update
|
|
53
|
+
BEFORE UPDATE ON audit_event
|
|
54
|
+
BEGIN
|
|
55
|
+
SELECT RAISE(ABORT, 'audit_event is append-only (ADR 0076)');
|
|
56
|
+
END;
|
|
57
|
+
|
|
58
|
+
CREATE TRIGGER trg_audit_event_no_delete
|
|
59
|
+
BEFORE DELETE ON audit_event
|
|
60
|
+
BEGIN
|
|
61
|
+
SELECT RAISE(ABORT, 'audit_event is append-only (ADR 0076)');
|
|
62
|
+
END;
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_SQLITE_DOWNGRADE = """
|
|
66
|
+
DROP TRIGGER trg_audit_event_no_delete;
|
|
67
|
+
|
|
68
|
+
DROP TRIGGER trg_audit_event_no_update;
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _statements(script: str) -> list[str]:
|
|
73
|
+
"""Split *script* on blank lines (each block is one statement, `;`-safe for triggers)."""
|
|
74
|
+
return [block.strip() for block in script.split("\n\n") if block.strip()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def upgrade() -> None:
|
|
78
|
+
dialect = op.get_context().dialect.name
|
|
79
|
+
if dialect == "postgresql":
|
|
80
|
+
for statement in _statements(_POSTGRES_UPGRADE):
|
|
81
|
+
op.execute(statement)
|
|
82
|
+
elif dialect == "sqlite":
|
|
83
|
+
for statement in _statements(_SQLITE_UPGRADE):
|
|
84
|
+
op.execute(statement)
|
|
85
|
+
# Any other (unverified) dialect gets no trigger: the application-level
|
|
86
|
+
# convention still holds, and production boot already requires an explicit
|
|
87
|
+
# DB_ALLOW_UNVERIFIED_DIALECT acknowledgement for such a database.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def downgrade() -> None:
|
|
91
|
+
dialect = op.get_context().dialect.name
|
|
92
|
+
if dialect == "postgresql":
|
|
93
|
+
for statement in _statements(_POSTGRES_DOWNGRADE):
|
|
94
|
+
op.execute(statement)
|
|
95
|
+
elif dialect == "sqlite":
|
|
96
|
+
for statement in _statements(_SQLITE_DOWNGRADE):
|
|
97
|
+
op.execute(statement)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""create audit tables
|
|
2
|
+
|
|
3
|
+
Revision ID: 586333320cdd
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-06-26 20:38:59.070766
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = '586333320cdd'
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table('audit_event',
|
|
27
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
28
|
+
sa.Column('action', sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False),
|
|
29
|
+
sa.Column('target_type', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
|
30
|
+
sa.Column('target_id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
|
31
|
+
sa.Column('actor_id', sa.Uuid(), nullable=True),
|
|
32
|
+
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
|
33
|
+
sa.Column('payload', sa.JSON(), nullable=True),
|
|
34
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
35
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_event'))
|
|
36
|
+
)
|
|
37
|
+
with op.batch_alter_table('audit_event', schema=None) as batch_op:
|
|
38
|
+
batch_op.create_index(batch_op.f('ix_audit_event_action'), ['action'], unique=False)
|
|
39
|
+
batch_op.create_index(batch_op.f('ix_audit_event_actor_id'), ['actor_id'], unique=False)
|
|
40
|
+
batch_op.create_index(batch_op.f('ix_audit_event_created_at'), ['created_at'], unique=False)
|
|
41
|
+
batch_op.create_index(batch_op.f('ix_audit_event_request_id'), ['request_id'], unique=False)
|
|
42
|
+
batch_op.create_index(batch_op.f('ix_audit_event_target_id'), ['target_id'], unique=False)
|
|
43
|
+
batch_op.create_index(batch_op.f('ix_audit_event_target_type'), ['target_type'], unique=False)
|
|
44
|
+
|
|
45
|
+
# ### end Alembic commands ###
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def downgrade() -> None:
|
|
49
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
50
|
+
with op.batch_alter_table('audit_event', schema=None) as batch_op:
|
|
51
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_target_type'))
|
|
52
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_target_id'))
|
|
53
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_request_id'))
|
|
54
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_created_at'))
|
|
55
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_actor_id'))
|
|
56
|
+
batch_op.drop_index(batch_op.f('ix_audit_event_action'))
|
|
57
|
+
|
|
58
|
+
op.drop_table('audit_event')
|
|
59
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The append-only audit log table — the durable record behind the core seam.
|
|
2
|
+
|
|
3
|
+
A row is one immutable fact: *actor* performed *action* on *target* during request
|
|
4
|
+
*request_id*, at *created_at*. It is written **only** by
|
|
5
|
+
:func:`terp.capabilities.audit.sink.persist_audit` (the sink ``create_app`` wires
|
|
6
|
+
into the core audit seam) and never updated or deleted, so it can answer "who did
|
|
7
|
+
what, when" long after the fact.
|
|
8
|
+
|
|
9
|
+
Append-only by design, it composes :class:`~terp.core.UUIDPrimaryKeyMixin` rather
|
|
10
|
+
than ``BaseTable``: there is no ``updated_at`` (rows never change) and no
|
|
11
|
+
optimistic-concurrency ``version`` (there is no concurrent write to a row). The
|
|
12
|
+
``action`` column is a plain string (the :class:`~terp.core.AuditAction` value) so
|
|
13
|
+
this low-layer capability needs no higher-layer enum.
|
|
14
|
+
|
|
15
|
+
``actor_id`` is an FK-less UUID on purpose: like the access capability's grant,
|
|
16
|
+
this leaf must not import the higher-layer user table it references, so it stays
|
|
17
|
+
something everything above it can depend on (never the reverse).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from sqlalchemy import JSON, DateTime
|
|
27
|
+
from sqlmodel import Column, Field, SQLModel
|
|
28
|
+
|
|
29
|
+
from terp.core import UUIDPrimaryKeyMixin
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _utc_now() -> datetime:
|
|
33
|
+
"""UTC ``now`` provider for this non-``BaseTable`` append-only row."""
|
|
34
|
+
return datetime.now(UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuditEvent(UUIDPrimaryKeyMixin, SQLModel, table=True): # arch-allow-table-models-use-base-table: append-only audit log has no updated_at/version by design (see module docstring)
|
|
38
|
+
__tablename__ = "audit_event"
|
|
39
|
+
|
|
40
|
+
action: str = Field(max_length=16, index=True)
|
|
41
|
+
target_type: str = Field(max_length=128, index=True)
|
|
42
|
+
target_id: str = Field(max_length=128, index=True)
|
|
43
|
+
actor_id: uuid.UUID | None = Field(default=None, index=True)
|
|
44
|
+
request_id: str | None = Field(default=None, max_length=64, index=True)
|
|
45
|
+
payload: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
|
|
46
|
+
created_at: datetime = Field(
|
|
47
|
+
default_factory=_utc_now,
|
|
48
|
+
sa_type=DateTime(timezone=True), # type: ignore[call-overload]
|
|
49
|
+
nullable=False,
|
|
50
|
+
index=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["AuditEvent"]
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Admin (read-only) audit-log router + the discoverable ``ModuleSpec``.
|
|
2
|
+
|
|
3
|
+
**Admin-only** (``Policy`` requires ``ADMIN``): the audit trail is privileged —
|
|
4
|
+
it records who did what across the whole app. Exposed as ``module`` so the
|
|
5
|
+
kernel's entry-point discovery mounts it at ``/api/v1/audit`` with no
|
|
6
|
+
composition-root edit. The log has no write surface here: rows are appended by the
|
|
7
|
+
sink inside each mutation's transaction, never through the API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
|
|
14
|
+
from terp.core import ADMIN, ModuleSpec, Page, PaginationDep, Policy, SessionDep
|
|
15
|
+
|
|
16
|
+
from terp.capabilities.audit.schemas import AuditEventRead
|
|
17
|
+
from terp.capabilities.audit.service import list_audit_events
|
|
18
|
+
|
|
19
|
+
router = APIRouter(tags=["audit"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/", response_model=Page[AuditEventRead])
|
|
23
|
+
def list_events(session: SessionDep, pagination: PaginationDep) -> Page[AuditEventRead]:
|
|
24
|
+
rows, total = list_audit_events(session, pagination=pagination)
|
|
25
|
+
return Page[AuditEventRead].of(
|
|
26
|
+
[AuditEventRead.model_validate(row) for row in rows], total, pagination
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
module = ModuleSpec(
|
|
31
|
+
name="audit",
|
|
32
|
+
router=router,
|
|
33
|
+
policy=Policy(read=ADMIN, write=ADMIN),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["module", "router"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Read DTOs for the audit log. The log is append-only — there is no write surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from terp.core import BaseSchema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditEventRead(BaseSchema):
|
|
13
|
+
id: uuid.UUID
|
|
14
|
+
action: str
|
|
15
|
+
target_type: str
|
|
16
|
+
target_id: str
|
|
17
|
+
actor_id: uuid.UUID | None
|
|
18
|
+
request_id: str | None
|
|
19
|
+
payload: dict[str, Any] | None
|
|
20
|
+
created_at: datetime.datetime
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["AuditEventRead"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Read access to the audit log — one paginated, newest-first query.
|
|
2
|
+
|
|
3
|
+
The log is append-only (written by the sink), so this capability exposes only a
|
|
4
|
+
read path: a single, bounded list used by the admin router. Mirrors the kernel's
|
|
5
|
+
pagination contract so the trail is browsed like any other ``Page[T]``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlmodel import Session, col, func, select
|
|
11
|
+
|
|
12
|
+
from terp.core import PaginationParams
|
|
13
|
+
|
|
14
|
+
from terp.capabilities.audit.models import AuditEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_audit_events(
|
|
18
|
+
session: Session, *, pagination: PaginationParams
|
|
19
|
+
) -> tuple[list[AuditEvent], int]:
|
|
20
|
+
"""Return one page of audit events, newest first, with the total count."""
|
|
21
|
+
total = session.exec(select(func.count()).select_from(AuditEvent)).one()
|
|
22
|
+
rows = session.exec(
|
|
23
|
+
select(AuditEvent)
|
|
24
|
+
.order_by(col(AuditEvent.created_at).desc(), col(AuditEvent.id).desc())
|
|
25
|
+
.offset(pagination.skip)
|
|
26
|
+
.limit(pagination.limit)
|
|
27
|
+
).all()
|
|
28
|
+
return list(rows), int(total)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ["list_audit_events"]
|
|
32
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The durable audit sink — turns a core :class:`AuditRecord` into a stored row.
|
|
2
|
+
|
|
3
|
+
``create_app(audit_sink=persist_audit)`` installs this on the core audit seam, so
|
|
4
|
+
every ``BaseService`` mutation appends an :class:`AuditEvent` **inside the caller's
|
|
5
|
+
transaction** — the row commits atomically with the business write, and a failure
|
|
6
|
+
here aborts the mutation rather than losing the trail (fail-closed).
|
|
7
|
+
|
|
8
|
+
The record reaches this point already centrally redacted (see
|
|
9
|
+
:meth:`terp.core.AuditPolicy.redact`); the sink only clips caller-influenceable
|
|
10
|
+
strings to their column bounds and strips NUL bytes, so a hostile or oversized
|
|
11
|
+
value can never break the INSERT — the audit trail must outlive bad input.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from sqlmodel import Session
|
|
17
|
+
|
|
18
|
+
from terp.core import AuditPolicy, AuditRecord, DurableAuditSink
|
|
19
|
+
|
|
20
|
+
from terp.capabilities.audit.models import AuditEvent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clip(value: str | None, limit: int) -> str | None:
|
|
24
|
+
"""Strip NUL bytes and clamp *value* to a bounded column's *limit*."""
|
|
25
|
+
if value is None:
|
|
26
|
+
return None
|
|
27
|
+
return value.replace("\x00", "")[:limit]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _persist_audit(session: Session, record: AuditRecord, policy: AuditPolicy) -> None:
|
|
31
|
+
"""Append *record* to the audit log within the caller's transaction (no commit).
|
|
32
|
+
|
|
33
|
+
The chokepoint that emitted *record* owns the commit, so the audit row rides
|
|
34
|
+
the same unit of work as the business mutation. *policy* is accepted to satisfy
|
|
35
|
+
the sink contract; redaction was already applied centrally before this call.
|
|
36
|
+
"""
|
|
37
|
+
event = AuditEvent(
|
|
38
|
+
action=record.action.value,
|
|
39
|
+
target_type=_clip(record.target_type, 128) or "",
|
|
40
|
+
target_id=_clip(record.target_id, 128) or "",
|
|
41
|
+
actor_id=record.actor_id,
|
|
42
|
+
request_id=_clip(record.request_id, 64),
|
|
43
|
+
payload=dict(record.payload) if record.payload else None,
|
|
44
|
+
)
|
|
45
|
+
session.add(event) # arch-allow-mutations-emit-audit: this IS the durable audit sink, the base of the write stack — it cannot route through BaseService
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
persist_audit = DurableAuditSink("terp.capabilities.audit.persist_audit", _persist_audit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["persist_audit"]
|