Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/development/WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,16 @@ Each group's PR closes its own sub-issue: `Closes #<sub-issue>` (fires the
board's Done automation for that item). Once every sub-issue under the
parent is closed, close the parent too — it's just a tracking issue at that
point.

## 6. Local lint enforcement before push

`scripts/git-hooks/pre-push` mirrors each service's CI lint step
(`black --check`, `mypy`, `flake8`) locally, scoped to whichever
service(s) the push actually touches (a `shared/` change checks all four).
One-time setup:

```
git config core.hooksPath scripts/git-hooks
```

Bypass with `git push --no-verify` when intentionally needed.
5 changes: 5 additions & 0 deletions openspec/changes/audit-mixin-coverage-guard/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
schema: spec-driven
created: 2026-09-01
priority: high
author: Arutsh
depends_on:
- audit-mixin-rollout-tier4
- shared-feat-299-audit-mixin-rollout-tier3
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
One task group = one GitHub ticket = one PR, merged before the next group starts.

## 1. ai service — depends on audit-mixin-auto-population being merged
## 1. ai service — depends on audit-mixin-auto-population being merged — Issue #301

- [ ] 1.1 Add Alembic migration adding nullable `created_by`/`updated_by` to `AIAuditLog`, `AIPrompt`, `UserProviderKey`, `CustomerAiDefaults` (via `AuditColumnsMixin`, non-`id` PK), `PrivilegedAccessLog`.
- [ ] 1.2 Update each model class to inherit `AuditMixin`/`AuditColumnsMixin` as appropriate.
- [ ] 1.3 For `PrivilegedAccessLog`, check whether an existing actor/subject field already captures the acting user; if so, assert it matches the auto-populated `created_by` in a test rather than treating them as unrelated.
- [ ] 1.4 Add/update tests confirming `created_by` is populated on creation for each of the 5 models, and `updated_by` behaves per the model's mutability (populated on update for mutable models, stays `NULL` for the append-only `AIAuditLog`/`PrivilegedAccessLog`).
- [ ] 1.5 Run `services/ai`'s test suite clean; PR merged.
- [x] 1.1 Add Alembic migration adding nullable `created_by`/`updated_by` to `AIAuditLog`, `AIPrompt`, `UserProviderKey`, `CustomerAiDefaults` (via `AuditColumnsMixin`, non-`id` PK), `PrivilegedAccessLog`.
- [x] 1.2 Update each model class to inherit `AuditMixin`/`AuditColumnsMixin` as appropriate.
- [x] 1.3 For `PrivilegedAccessLog`, check whether an existing actor/subject field already captures the acting user; if so, assert it matches the auto-populated `created_by` in a test rather than treating them as unrelated.
- [x] 1.4 Add/update tests confirming `created_by` is populated on creation for each of the 5 models, and `updated_by` behaves per the model's mutability (populated on update for mutable models, stays `NULL` for the append-only `AIAuditLog`/`PrivilegedAccessLog`).
- [x] 1.5 Run `services/ai`'s test suite clean; PR merged.

## 2. chat service — depends on 1

Expand Down
7 changes: 3 additions & 4 deletions services/ai/app/models/audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.orm import mapped_column, Mapped
from app.models.base import Base
from shared.db.audit_mixin import AuditMixin
import shared.db.type_decorators as t


class AIAuditLog(Base):
class AIAuditLog(Base, AuditMixin):
__tablename__ = "ai_audit_logs"

id: Mapped[t.GUID] = mapped_column(
t.GUID(), primary_key=True, default=lambda: str(uuid.uuid4())
)
id: Mapped[uuid.UUID] = mapped_column(t.GUID(), primary_key=True, default=lambda: uuid.uuid4())
customer_id: Mapped[t.GUID] = mapped_column(t.GUID(), nullable=False, index=True)
user_id: Mapped[t.GUID] = mapped_column(t.GUID(), nullable=False, index=True)
prompt_version: Mapped[str] = mapped_column(String, nullable=False)
Expand Down
3 changes: 2 additions & 1 deletion services/ai/app/models/customer_ai_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from sqlalchemy.orm import mapped_column, Mapped

from app.models.base import Base
from shared.db.audit_mixin import AuditColumnsMixin
import shared.db.type_decorators as t


class CustomerAiDefaults(Base):
class CustomerAiDefaults(Base, AuditColumnsMixin):
__tablename__ = "customer_ai_defaults"

customer_id: Mapped[t.GUID] = mapped_column(t.GUID(), primary_key=True)
Expand Down
7 changes: 3 additions & 4 deletions services/ai/app/models/privileged_access_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import Base
from shared.db.audit_mixin import AuditMixin
import shared.db.type_decorators as t


class PrivilegedAccessLog(Base):
class PrivilegedAccessLog(Base, AuditMixin):
"""Append-only — no update/delete path exists anywhere in the app."""

__tablename__ = "privileged_access_logs"

id: Mapped[t.GUID] = mapped_column(
t.GUID(), primary_key=True, default=lambda: str(uuid.uuid4())
)
id: Mapped[uuid.UUID] = mapped_column(t.GUID(), primary_key=True, default=lambda: uuid.uuid4())
actor_user_id: Mapped[t.GUID] = mapped_column(t.GUID(), nullable=False, index=True)
customer_id: Mapped[t.GUID] = mapped_column(t.GUID(), nullable=False, index=True)
method: Mapped[str] = mapped_column(String, nullable=False)
Expand Down
7 changes: 3 additions & 4 deletions services/ai/app/models/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import mapped_column, Mapped
from app.models.base import Base
from shared.db.audit_mixin import AuditMixin
import shared.db.type_decorators as t


class AIPrompt(Base):
class AIPrompt(Base, AuditMixin):
__tablename__ = "ai_prompts"

id: Mapped[t.GUID] = mapped_column(
t.GUID(), primary_key=True, default=lambda: str(uuid.uuid4())
)
id: Mapped[uuid.UUID] = mapped_column(t.GUID(), primary_key=True, default=lambda: uuid.uuid4())
name: Mapped[str] = mapped_column(String, nullable=False, index=True)
version: Mapped[str] = mapped_column(String, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
Expand Down
7 changes: 3 additions & 4 deletions services/ai/app/models/user_provider_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
from sqlalchemy.orm import mapped_column, Mapped, relationship

from app.models.base import Base
from shared.db.audit_mixin import AuditMixin
import shared.db.type_decorators as t


class UserProviderKey(Base):
class UserProviderKey(Base, AuditMixin):
__tablename__ = "user_provider_keys"

id: Mapped[t.GUID] = mapped_column(
t.GUID(), primary_key=True, default=lambda: str(uuid.uuid4())
)
id: Mapped[uuid.UUID] = mapped_column(t.GUID(), primary_key=True, default=lambda: uuid.uuid4())
user_id: Mapped[t.GUID] = mapped_column(t.GUID(), nullable=False, index=True)
customer_id: Mapped[t.GUID | None] = mapped_column(t.GUID(), nullable=True, index=True)
provider_id: Mapped[t.GUID] = mapped_column(
Expand Down
51 changes: 51 additions & 0 deletions services/ai/migrations/versions/017_tier3_audit_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Add created_by/updated_by (audit-mixin-rollout-tier3, group 1)

Revision ID: 017_tier3_audit_columns
Revises: 016_ai_provider_models
Create Date: 2026-09-20 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

from shared.db.type_decorators import GUID

revision: str = "017_tier3_audit_columns"
down_revision: Union[str, Sequence[str], None] = "016_ai_provider_models"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

# table -> extra AuditMixin/AuditColumnsMixin columns it doesn't already have,
# beyond created_by/updated_by (added to every table below).
_EXTRA_COLUMNS = {
"ai_audit_logs": ["updated_at"],
"ai_prompts": ["updated_at"],
"user_provider_keys": [],
"customer_ai_defaults": ["created_at"],
"privileged_access_logs": ["updated_at"],
}


def _column(name: str) -> sa.Column:
if name in ("created_at", "updated_at"):
return sa.Column(name, sa.DateTime(timezone=True), nullable=True)
return sa.Column(name, GUID(), nullable=True)


def upgrade() -> None:
for table, extra in _EXTRA_COLUMNS.items():
op.add_column(table, _column("created_by"))
op.add_column(table, _column("updated_by"))
for name in extra:
op.add_column(table, _column(name))


def downgrade() -> None:
for table, extra in _EXTRA_COLUMNS.items():
op.drop_column(table, "created_by")
op.drop_column(table, "updated_by")
for name in extra:
op.drop_column(table, name)
22 changes: 17 additions & 5 deletions services/ai/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,28 @@
from main import app # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.privileged_access_log import PrivilegedAccessLog # noqa: E402
from app.models.audit_log import AIAuditLog # noqa: E402
from app.models.prompt import AIPrompt # noqa: E402
from app.models.user_provider_key import UserProviderKey # noqa: E402
from app.models.customer_ai_defaults import CustomerAiDefaults # noqa: E402
from app.models.ai_provider import AIProvider # noqa: E402

_DB_TABLES = [
PrivilegedAccessLog.__table__,
AIAuditLog.__table__,
AIPrompt.__table__,
AIProvider.__table__,
UserProviderKey.__table__,
CustomerAiDefaults.__table__,
]


@pytest.fixture
def db():
"""Real in-memory sqlite session covering PrivilegedAccessLog — sync,
matching this sink's deliberately sync design (see
app/services/privileged_access_audit.py). Add tables here as more tests
need a real DB session for this service."""
"""Real in-memory sqlite session — sync, matching this service's sync
audit sinks. Add tables to _DB_TABLES as more tests need a real DB session."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine, tables=[PrivilegedAccessLog.__table__])
Base.metadata.create_all(engine, tables=_DB_TABLES)
return sessionmaker(bind=engine)()


Expand Down
Loading
Loading