Skip to content

Commit 4a570eb

Browse files
Merge pull request #313 from bg-playground/copilot/297-wire-audit-log-endpoints
External Results audit closure: actor-aware audit model + runner-token write-path logging
2 parents c9ba7d6 + 9d263c2 commit 4a570eb

10 files changed

Lines changed: 410 additions & 16 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""add actor_kind and actor_token_id to audit_log
2+
3+
Revision ID: i8j9k0l1m2n3
4+
Revises: h7i8j9k0l1m2
5+
Create Date: 2026-05-07 14:30:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
14+
from alembic import op
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "i8j9k0l1m2n3"
18+
down_revision: Union[str, None] = "h7i8j9k0l1m2"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.add_column("audit_log", sa.Column("actor_kind", sa.String(length=20), nullable=False, server_default="user"))
25+
op.add_column("audit_log", sa.Column("actor_token_id", postgresql.UUID(as_uuid=True), nullable=True))
26+
op.alter_column("audit_log", "user_id", existing_type=postgresql.UUID(as_uuid=True), nullable=True)
27+
28+
op.execute("UPDATE audit_log SET actor_kind = 'user' WHERE actor_kind IS NULL")
29+
30+
op.create_foreign_key(
31+
"fk_audit_log_actor_token_id_runner_tokens",
32+
"audit_log",
33+
"runner_tokens",
34+
["actor_token_id"],
35+
["id"],
36+
ondelete="SET NULL",
37+
)
38+
op.create_check_constraint(
39+
"ck_audit_log_actor_identity",
40+
"audit_log",
41+
"(actor_kind = 'user' AND user_id IS NOT NULL AND actor_token_id IS NULL) "
42+
"OR (actor_kind = 'runner_token' AND actor_token_id IS NOT NULL AND user_id IS NULL)",
43+
)
44+
op.create_index("idx_audit_log_actor_token_id", "audit_log", ["actor_token_id"])
45+
op.create_index("idx_audit_log_actor_kind_created_at", "audit_log", ["actor_kind", "created_at"])
46+
47+
48+
def downgrade() -> None:
49+
op.drop_index("idx_audit_log_actor_kind_created_at", table_name="audit_log")
50+
op.drop_index("idx_audit_log_actor_token_id", table_name="audit_log")
51+
op.drop_constraint("ck_audit_log_actor_identity", "audit_log", type_="check")
52+
op.drop_constraint("fk_audit_log_actor_token_id_runner_tokens", "audit_log", type_="foreignkey")
53+
54+
# Restore non-null user_id invariant for pre-actor rows.
55+
op.execute("DELETE FROM audit_log WHERE user_id IS NULL")
56+
op.alter_column("audit_log", "user_id", existing_type=postgresql.UUID(as_uuid=True), nullable=False)
57+
op.drop_column("audit_log", "actor_token_id")
58+
op.drop_column("audit_log", "actor_kind")

backend/app/api/audit_log.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
@router.get("/audit-log", response_model=AuditLogListResponse)
1919
async def list_audit_logs(
20+
actor_kind: str | None = Query(None, description="Filter by actor kind ('user' or 'runner_token')"),
2021
user_id: UUID | None = Query(None, description="Filter by user ID"),
22+
actor_token_id: UUID | None = Query(None, description="Filter by runner token ID"),
2123
action: str | None = Query(None, description="Filter by action (e.g. 'requirement.created')"),
2224
resource_type: str | None = Query(None, description="Filter by resource type"),
2325
date_from: datetime | None = Query(None, description="Filter entries on or after this datetime"),
@@ -30,7 +32,9 @@ async def list_audit_logs(
3032
"""List audit log entries (admin only)."""
3133
entries, total = await get_audit_logs(
3234
db,
35+
actor_kind=actor_kind,
3336
user_id=user_id,
37+
actor_token_id=actor_token_id,
3438
action=action,
3539
resource_type=resource_type,
3640
date_from=date_from,

backend/app/api/external_results.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
get_current_runner_token, # noqa: F401 — used inside _get_session_auth
2020
require_runner_scope,
2121
)
22+
from app.crud.audit_log import write_audit
2223
from app.crud.external_results import create_session, finish_session_db, get_session
2324
from app.db.session import get_db
2425
from app.models.runner_token import RunnerToken
@@ -72,6 +73,20 @@ async def create_external_session(
7273
last 60 seconds (idempotency window).
7374
"""
7475
session = await create_session(db, payload=payload, runner_token_id=token.id)
76+
await write_audit(
77+
db,
78+
actor_kind="runner_token",
79+
actor_id=token.id,
80+
action="external_results.session.start",
81+
resource_type="external_session",
82+
resource_id=session.id,
83+
details={
84+
"project_id": str(payload.project_id),
85+
"git_sha": payload.git_sha,
86+
"git_branch": payload.git_branch,
87+
"runner": payload.runner,
88+
},
89+
)
7590
return _session_to_response(session)
7691

7792

@@ -88,7 +103,7 @@ async def finish_external_session(
88103
session_id: UUID,
89104
payload: SessionFinish,
90105
db: AsyncSession = Depends(get_db),
91-
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)), # noqa: ARG001
106+
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
92107
) -> SessionResponse:
93108
"""Set the terminal status of a session.
94109
@@ -107,6 +122,19 @@ async def finish_external_session(
107122
detail={"code": "session.not_found", "message": f"Session {session_id} does not exist.", "details": None},
108123
)
109124

125+
await write_audit(
126+
db,
127+
actor_kind="runner_token",
128+
actor_id=token.id,
129+
action="external_results.session.finish",
130+
resource_type="external_session",
131+
resource_id=session.id,
132+
details={
133+
"status": session.status.value,
134+
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
135+
},
136+
)
137+
110138
return _session_to_response(session)
111139

112140

backend/app/crud/audit_log.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""CRUD operations for Audit Log"""
22

33
from datetime import datetime
4+
from typing import Literal
45
from uuid import UUID
56

67
from sqlalchemy import func, select
@@ -9,6 +10,36 @@
910
from app.models.audit_log import AuditLog
1011

1112

13+
async def write_audit(
14+
db: AsyncSession,
15+
*,
16+
actor_kind: Literal["user", "runner_token"],
17+
actor_id: UUID,
18+
action: str,
19+
resource_type: str,
20+
resource_id: str | UUID,
21+
details: dict | None = None,
22+
) -> AuditLog:
23+
"""Create a new audit log entry for a user or runner token actor."""
24+
entry_kwargs = {
25+
"actor_kind": actor_kind,
26+
"action": action,
27+
"resource_type": resource_type,
28+
"resource_id": str(resource_id),
29+
"details": details,
30+
}
31+
if actor_kind == "user":
32+
entry_kwargs["user_id"] = actor_id
33+
else:
34+
entry_kwargs["actor_token_id"] = actor_id
35+
36+
entry = AuditLog(**entry_kwargs)
37+
db.add(entry)
38+
await db.commit()
39+
await db.refresh(entry)
40+
return entry
41+
42+
1243
async def create_audit_entry(
1344
db: AsyncSession,
1445
user_id: UUID,
@@ -17,23 +48,23 @@ async def create_audit_entry(
1748
resource_id: str,
1849
details: dict | None = None,
1950
) -> AuditLog:
20-
"""Create a new audit log entry."""
21-
entry = AuditLog(
22-
user_id=user_id,
51+
"""Backward-compatible user-actor shim for audit writes."""
52+
return await write_audit(
53+
db,
54+
actor_kind="user",
55+
actor_id=user_id,
2356
action=action,
2457
resource_type=resource_type,
2558
resource_id=resource_id,
2659
details=details,
2760
)
28-
db.add(entry)
29-
await db.commit()
30-
await db.refresh(entry)
31-
return entry
3261

3362

3463
async def get_audit_logs(
3564
db: AsyncSession,
3665
user_id: UUID | None = None,
66+
actor_kind: str | None = None,
67+
actor_token_id: UUID | None = None,
3768
action: str | None = None,
3869
resource_type: str | None = None,
3970
date_from: datetime | None = None,
@@ -46,6 +77,10 @@ async def get_audit_logs(
4677

4778
if user_id is not None:
4879
query = query.where(AuditLog.user_id == user_id)
80+
if actor_kind is not None:
81+
query = query.where(AuditLog.actor_kind == actor_kind)
82+
if actor_token_id is not None:
83+
query = query.where(AuditLog.actor_token_id == actor_token_id)
4984
if action is not None:
5085
query = query.where(AuditLog.action == action)
5186
if resource_type is not None:

backend/app/crud/external_results.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ async def create_session(
3030
seconds by the same runner token, the existing session is returned instead
3131
of creating a duplicate.
3232
33-
# TODO(#297): Write audit entry ``external_results.session.start`` here.
3433
"""
3534
cutoff = datetime.now(tz=timezone.utc).replace(tzinfo=None) - timedelta(seconds=_IDEMPOTENCY_WINDOW_SECONDS)
3635

@@ -90,7 +89,6 @@ async def finish_session_db(
9089
Raises ``ValueError`` with a structured dict payload on transition
9190
violations so the API layer can return the appropriate 409.
9291
93-
# TODO(#297): Write audit entry ``external_results.session.finish`` here.
9492
"""
9593
result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id))
9694
session = result.scalar_one_or_none()

backend/app/models/audit_log.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import uuid
22

3-
from sqlalchemy import Column, DateTime, ForeignKey, String
3+
from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Index, String
44
from sqlalchemy.sql import func
55

66
from .base import Base
@@ -9,9 +9,24 @@
99

1010
class AuditLog(Base):
1111
__tablename__ = "audit_log"
12+
__table_args__ = (
13+
CheckConstraint(
14+
"(actor_kind = 'user' AND user_id IS NOT NULL AND actor_token_id IS NULL) "
15+
"OR (actor_kind = 'runner_token' AND actor_token_id IS NOT NULL AND user_id IS NULL)",
16+
name="ck_audit_log_actor_identity",
17+
),
18+
Index("idx_audit_log_actor_kind_created_at", "actor_kind", "created_at"),
19+
)
1220

1321
id = Column(GUID(), primary_key=True, default=uuid.uuid4)
14-
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False, index=True)
22+
actor_kind = Column(String(20), nullable=False, server_default="user")
23+
user_id = Column(GUID(), ForeignKey("users.id"), nullable=True, index=True)
24+
actor_token_id = Column(
25+
GUID(),
26+
ForeignKey("runner_tokens.id", ondelete="SET NULL"),
27+
nullable=True,
28+
index=True,
29+
)
1530
action = Column(String(100), nullable=False, index=True)
1631
resource_type = Column(String(50), nullable=False, index=True)
1732
resource_id = Column(String(36), nullable=False)

backend/app/schemas/audit_log.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
class AuditLogResponse(BaseModel):
99
id: UUID
10-
user_id: UUID
10+
actor_kind: str
11+
user_id: UUID | None
12+
actor_token_id: UUID | None
1113
action: str
1214
resource_type: str
1315
resource_id: str

0 commit comments

Comments
 (0)