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.
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-audit
3
+ Version: 0.1.0
4
+ Summary: Terp audit capability — the durable, append-only sink for the core audit seam.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,4 @@
1
+ {
2
+ "arch-allow-mutations-emit-audit": 1,
3
+ "arch-allow-table-models-use-base-table": 1
4
+ }
@@ -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"]
@@ -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"]