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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ All notable changes to this project are documented in this file. The format is b
- `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46.
- `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46.
- `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged.
- `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain.


- Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review).
Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ when a later module PR moves files.
| Module | Root `.py` files | Flat `tests/test_*.py` | State |
| --- | ---: | ---: | --- |
| api_tokens | 3 (+4 shims) | 0 | follows this layout |
| audit_log | 13 | 18 | flat |
| audit_log | 3 | 0 | follows this layout |
| auth | 8 | 20 | flat |
| background_tasks | 49 | 83 | flat |
| branding | 2 (+6 shims) | 0 | follows this layout |
Expand Down
2 changes: 1 addition & 1 deletion docs/modules/audit_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ A worker never runs `create_app`, so it has no `app.state.sm.db` for `on_startup

```toml
[project.entry-points."simple_module.audit_sinks"]
audit_log = "audit_log.capture:audit_callback"
audit_log = "audit_log.core.capture:audit_callback"
```

`background_tasks` binds whatever `simple_module_db.audit_sinks.discover_audit_callback()` finds to both of its worker engines — the sync engine task bodies and signal handlers use (`sync_db`) and the async engine durable outbox handlers run on (`outbox_worker`). A task's ORM writes, including `TaskExecution` lifecycle rows, are therefore audited exactly like a request's whenever `audit_log` is installed; neither package imports the other. The worker's audit rows carry no `user_id` (no request principal).
Expand Down
2 changes: 1 addition & 1 deletion docs/perf/2026-08-02-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ above are with prepared statements warm, as in steady-state serving.)

Cumulative on the audit-log navigation: **86.2 ms → 37.0 ms, −57 %**.

Tests: `modules/audit_log/tests/test_distinct_entity_types.py` covers
Tests: `modules/audit_log/tests/entries/service/test_audit_log_distinct_entity_types.py` covers
behaviour on the SQLite path and asserts the dialect branch selection, so the
Postgres query cannot silently become the SQLite one.

Expand Down
2 changes: 1 addition & 1 deletion framework/core/tests/test_sm003_page_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def test_resolves_annotated_fstring_constant(self, tmp_path: Path):
endpoints = src_dir / "endpoints"
endpoints.mkdir()
(endpoints / "views.py").write_text(
"from audit_log.constants import PAGE_BROWSE\n"
"from audit_log.core.constants import PAGE_BROWSE\n"
"async def view(inertia):\n"
" return await inertia.render(PAGE_BROWSE, {})\n"
)
Expand Down
2 changes: 1 addition & 1 deletion framework/db/simple_module_db/audit_sinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
declares::

[project.entry-points."simple_module.audit_sinks"]
audit_log = "audit_log.capture:audit_callback"
audit_log = "audit_log.core.capture:audit_callback"

and the worker binds the result to its engine with
``EngineTenancy(audit_callback=...)``. Neither side imports the other (#498).
Expand Down
7 changes: 1 addition & 6 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
# Add named domain areas here only when they contain several related features.
DOMAIN_AREAS: dict[str, frozenset[str]] = {
"api_tokens": frozenset({"credentials"}),
"audit_log": frozenset({"entries"}),
"branding": frozenset({"assets"}),
"keycloak": frozenset({"identity"}),
"mail": frozenset({"delivery"}),
Expand All @@ -43,12 +44,6 @@

# Frozen inventory, not a computed baseline: moves must remove their old entries.
LEGACY_FLAT: dict[str, frozenset[str]] = {
"audit_log": frozenset(
"""
activity.py capture.py constants.py deps.py endpoints export.py filters.py
resolve.py service.py services.py settings.py
""".split()
),
"auth": frozenset(
"""
_route_match.py constants.py deps.py middleware.py oidc_common state.py
Expand Down
4 changes: 4 additions & 0 deletions modules/audit_log/audit_log/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""What every audit_log feature builds on.

Constants, settings, the app.state container and the audit capture callback.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dataclasses import dataclass

from audit_log.settings import AuditLogSettings
from audit_log.core.settings import AuditLogSettings


@dataclass
Expand Down
5 changes: 5 additions & 0 deletions modules/audit_log/audit_log/entries/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Reading audit entries.

The query service, shared filters, id resolution, the activity feed and CSV
export.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

from simple_module_core.activity_feed import ActivityItem

from audit_log.resolve import resolve_entity_labels
from audit_log.service import AuditLogService
from audit_log.entries.resolve import resolve_entity_labels
from audit_log.entries.service import AuditLogService


class AuditActivityFeed:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
from sqlalchemy.ext.asyncio import AsyncSession

from audit_log.contracts.schemas import AuditEntryRead
from audit_log.filters import EntryFilters
from audit_log.resolve import resolve_actors, resolve_entity_labels
from audit_log.service import AuditLogService
from audit_log.entries.filters import EntryFilters
from audit_log.entries.resolve import resolve_actors, resolve_entity_labels
from audit_log.entries.service import AuditLogService

CSV_COLUMNS = (
"time",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from datetime import datetime, time
from typing import Any

from audit_log.constants import PLATFORM_TENANT_FILTER
from audit_log.core.constants import PLATFORM_TENANT_FILTER
from audit_log.models import AuditEntry


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col

from audit_log.constants import DEFAULT_PAGE_SIZE, MAX_PAGE, MAX_PAGE_SIZE
from audit_log.contracts.schemas import AuditEntryList, AuditEntryRead
from audit_log.filters import EntryFilters
from audit_log.core.constants import DEFAULT_PAGE_SIZE, MAX_PAGE, MAX_PAGE_SIZE
from audit_log.entries.filters import EntryFilters
from audit_log.models import AuditEntry

_ENTITY_TYPE_CTE = "distinct_entity_type"
Expand Down
2 changes: 1 addition & 1 deletion modules/audit_log/audit_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sqlalchemy import JSON, Index, func
from sqlmodel import Column, Field

from audit_log.constants import (
from audit_log.core.constants import (
ACTION_MAX_LENGTH,
CORRELATION_ID_MAX_LENGTH,
ENTITY_ID_MAX_LENGTH,
Expand Down
14 changes: 7 additions & 7 deletions modules/audit_log/audit_log/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from simple_module_core.module_settings import register_module_settings
from simple_module_core.permissions import PermissionRegistry

from audit_log.constants import (
from audit_log.core.constants import (
ALL_PERMISSIONS,
API_PREFIX,
LOCALE_NAMESPACE,
Expand All @@ -36,16 +36,16 @@ class AuditLogModule(ModuleBase):
)

def register_settings(self, app: FastAPI) -> None:
from audit_log.services import AuditLogServices
from audit_log.settings import AuditLogSettings
from audit_log.core.services import AuditLogServices
from audit_log.core.settings import AuditLogSettings

register_module_settings(
app, MODULE_PACKAGE, AuditLogSettings, lambda s: AuditLogServices(settings=s)
)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
from audit_log.endpoints.api import router as api
from audit_log.endpoints.views import router as views
from audit_log.web.endpoints.api import router as api
from audit_log.web.endpoints.views import router as views

api_router.include_router(api)
view_router.include_router(views)
Expand Down Expand Up @@ -77,8 +77,8 @@ def register_permissions(self, registry: PermissionRegistry) -> None:
async def on_startup(self, app: FastAPI) -> None:
from simple_module_core.activity_feed import set_activity_feed

from audit_log.activity import AuditActivityFeed
from audit_log.capture import audit_callback
from audit_log.core.capture import audit_callback
from audit_log.entries.activity import AuditActivityFeed

db = app.state.sm.db
db.audit_callback = audit_callback
Expand Down
8 changes: 4 additions & 4 deletions modules/audit_log/audit_log/pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import { Card } from '@simple-module-py/ui/components/ui/card';
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
import { Download } from 'lucide-react';
import { useState } from 'react';
import { BrowseEmpty } from './components/BrowseEmpty';
import { CorrelationBanner } from './components/Correlation';
import { type AuditEntryRead, EntriesTable } from './components/EntriesTable';
import { BrowseEmpty } from '../ui/components/BrowseEmpty';
import { CorrelationBanner } from '../ui/components/Correlation';
import { type AuditEntryRead, EntriesTable } from '../ui/components/EntriesTable';
import {
ALL,
type AppliedFilters,
type EntityTypeOption,
FilterBar,
type FilterState,
} from './components/FilterBar';
} from '../ui/components/FilterBar';

interface Props {
items: AuditEntryRead[];
Expand Down
1 change: 1 addition & 0 deletions modules/audit_log/audit_log/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The HTTP side: request deps and the REST and Inertia endpoints."""
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from simple_module_db.deps import get_db
from sqlalchemy.ext.asyncio import AsyncSession

from audit_log.service import AuditLogService
from audit_log.entries.service import AuditLogService


async def get_audit_log_service(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for
from sqlalchemy.ext.asyncio import AsyncSession

from audit_log.constants import DEFAULT_PAGE_SIZE, PERM_VIEW
from audit_log.contracts.schemas import AuditEntryList
from audit_log.deps import AuditLogServiceDep
from audit_log.export import CSV_FILENAME, CSV_MEDIA_TYPE, stream_csv
from audit_log.filters import EntryFilters
from audit_log.resolve import actor_filter
from audit_log.core.constants import DEFAULT_PAGE_SIZE, PERM_VIEW
from audit_log.entries.export import CSV_FILENAME, CSV_MEDIA_TYPE, stream_csv
from audit_log.entries.filters import EntryFilters
from audit_log.entries.resolve import actor_filter
from audit_log.web.deps import AuditLogServiceDep

router = APIRouter()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@
from simple_module_inertia import InertiaResponse
from sqlalchemy.ext.asyncio import AsyncSession

from audit_log.constants import (
from audit_log.core.constants import (
API_PREFIX,
DEFAULT_PAGE_SIZE,
PAGE_BROWSE,
PERM_VIEW,
PLATFORM_TENANT_FILTER,
)
from audit_log.deps import AuditLogServiceDep
from audit_log.filters import EntryFilters
from audit_log.resolve import (
from audit_log.entries.filters import EntryFilters
from audit_log.entries.resolve import (
actor_filter,
actor_link,
entity_link,
resolve_actors,
resolve_entity_labels,
)
from audit_log.web.deps import AuditLogServiceDep

router = APIRouter()

Expand Down
2 changes: 1 addition & 1 deletion modules/audit_log/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ audit_log = "audit_log.module:AuditLogModule"
# Read by processes that never build the app (the Celery worker), so their
# writes are audited too (#498). See ``simple_module_db.audit_sinks``.
[project.entry-points."simple_module.audit_sinks"]
audit_log = "audit_log.capture:audit_callback"
audit_log = "audit_log.core.capture:audit_callback"

[project.urls]
Homepage = "https://github.com/antosubash/simple_module_python"
Expand Down
2 changes: 1 addition & 1 deletion modules/audit_log/tests-js/BrowseEmpty.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ configureI18n({
},
});

import { BrowseEmpty } from '../audit_log/pages/components/BrowseEmpty';
import { BrowseEmpty } from '../audit_log/ui/components/BrowseEmpty';

const NONE = {
entity_type: null,
Expand Down
2 changes: 1 addition & 1 deletion modules/audit_log/tests-js/ChangesList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ configureI18n({
},
});

import { type Change, ChangesList } from '../audit_log/pages/components/ChangesList';
import { type Change, ChangesList } from '../audit_log/ui/components/ChangesList';

const FOUR: Change[] = [
{ field: 'value', old: '', new: 'mail.example.com' },
Expand Down
2 changes: 1 addition & 1 deletion modules/audit_log/tests-js/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
formatEntryTime,
parseIsoDate,
toIsoDate,
} from '../audit_log/pages/components/format';
} from '../audit_log/ui/components/format';

describe('formatEntryTime', () => {
test('renders the deck format: d MMM HH:mm:ss', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

import httpx
from audit_log.constants import ACTION_UPDATED
from audit_log.core.constants import ACTION_UPDATED
from audit_log.models import AuditEntry

VIEW_URL = "/admin/audit-log/"
Expand Down
4 changes: 4 additions & 0 deletions modules/audit_log/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Fixtures shared by every audit_log test folder.

Its presence puts this directory on ``sys.path``, so ``_audit_log_support`` imports.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
import io

import pytest
from audit_log.capture import audit_callback
from audit_log.constants import PLATFORM_TENANT_FILTER
from audit_log.core.capture import audit_callback
from audit_log.core.constants import PLATFORM_TENANT_FILTER
from audit_log.models import AuditEntry
from simple_module_core.tenancy import TenantRole
from simple_module_db import all_tenants, tenant_context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import uuid
from types import SimpleNamespace

from audit_log.activity import AuditActivityFeed
from audit_log.entries.activity import AuditActivityFeed
from audit_log.models import AuditEntry
from simple_module_core.activity_feed import get_activity_feed
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from datetime import UTC, datetime

import httpx
from audit_log.constants import ACTION_CREATED, ACTION_DELETED, ACTION_UPDATED
from audit_log.core.constants import ACTION_CREATED, ACTION_DELETED, ACTION_UPDATED
from audit_log.models import AuditEntry
from simple_module_test import forge_session_cookie
from users.models import User
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import io

import httpx
from audit_log.constants import ACTION_UPDATED
from audit_log.core.constants import ACTION_UPDATED
from audit_log.models import AuditEntry
from users.models import User

Expand Down Expand Up @@ -68,13 +68,13 @@ async def test_a_formula_display_name_is_neutralised(
assert row["actor"] == f"'{payload}"

def test_every_dangerous_lead_character_is_prefixed(self) -> None:
from audit_log.export import escape_formula
from audit_log.entries.export import escape_formula

for lead in ("=", "+", "-", "@", "\t", "\r"):
assert escape_formula(f"{lead}cmd") == f"'{lead}cmd"

def test_ordinary_text_is_untouched(self) -> None:
from audit_log.export import escape_formula
from audit_log.entries.export import escape_formula

assert escape_formula("Dana Rivera") == "Dana Rivera"
assert escape_formula("") == ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import uuid

import httpx
from audit_log.constants import ACTION_UPDATED
from audit_log.core.constants import ACTION_UPDATED
from audit_log.models import AuditEntry
from users.models import User

Expand Down Expand Up @@ -153,7 +153,7 @@ async def test_the_filter_is_a_subquery_not_a_list_of_ids(self) -> None:
"""Pins the mechanism, not just today's row count: an implementation
that materialises ids passes the count test right up until the install
is big enough to break it."""
from audit_log.resolve import actor_filter
from audit_log.entries.resolve import actor_filter
from users.directory import UsersDirectory

rendered = str(actor_filter(UsersDirectory(), "okafor"))
Expand All @@ -164,7 +164,7 @@ async def test_the_filter_is_a_subquery_not_a_list_of_ids(self) -> None:
async def test_without_a_directory_the_term_is_an_exact_actor_id(self) -> None:
"""No directory can say who is called that, so the box falls back to
an id match (a worker name) instead of silently matching nobody."""
from audit_log.resolve import actor_filter
from audit_log.entries.resolve import actor_filter
from simple_module_core.user_directory import NullUserDirectory

rendered = str(actor_filter(NullUserDirectory(), "celery-worker-1"))
Expand Down
Loading
Loading