Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c6dfe00
docs: features batch 3 design (#557 #540 #535 #541 #526)
antosubash Oct 21, 2026
a80aaad
feat(inertia): server-rendered <head> for public pages (#535 step 1)
antosubash Oct 21, 2026
6d3885f
feat(cli): smpy docker add-worker scaffolds separately built worker i…
antosubash Oct 21, 2026
c47e811
Merge branch 'slice/b3-b-workers' into feat/features-batch-3
antosubash Oct 21, 2026
bd98037
feat(core,db,hosting): resource-scoped access — ResourcePolicy, Owned…
antosubash Oct 22, 2026
23fde7f
feat(audit): audit bulk, Core and raw DML writes (#557)
antosubash Oct 22, 2026
9a2380f
feat: legacy SPA mounts with deep-link fallback and scoped link inter…
antosubash Oct 22, 2026
1570cf7
Merge branch 'slice/b3-a-bulkaudit' into feat/features-batch-3
antosubash Oct 22, 2026
ef29f9d
fix(diagnostics): SPA mount overlap is SM032 (SM031 is the unguarded-…
antosubash Oct 22, 2026
5f15a08
Merge branch 'slice/b3-d-spa' into feat/features-batch-3
antosubash Oct 22, 2026
b38734f
fix(db): OwnedResourcePolicy.can compares owners exactly as filter do…
antosubash Oct 22, 2026
ccdeae3
fix(spa-mounts): overlap check sees registered routes, scoped public …
antosubash Oct 22, 2026
ba75933
fix(audit): bulk audit reads real SET targets; every soft-delete shap…
antosubash Oct 22, 2026
2ae7c6a
Merge branch 'slice/b3-a-bulkaudit' into feat/features-batch-3
antosubash Oct 22, 2026
b0bccb0
Merge branch 'slice/b3-d-spa' into feat/features-batch-3
antosubash Oct 22, 2026
22edcdb
fix(audit): per-row soft-delete actions; executemany targets union st…
antosubash Oct 22, 2026
ee14d84
Merge branch 'slice/b3-a-bulkaudit' into feat/features-batch-3
antosubash Oct 22, 2026
cd970de
feat(sharing): optional generic share module for resource-scoped acce…
antosubash Oct 22, 2026
0311ad2
docs: resource access + sharing module pages, migration graph, change…
antosubash Oct 22, 2026
117f2d5
Merge branch 'slice/b3-e-resources' into feat/features-batch-3
antosubash Oct 22, 2026
2277341
fix(audit): an INSERT is always audited; soft-delete flag is bookkeep…
antosubash Oct 22, 2026
62c6569
Merge branch 'slice/b3-a-bulkaudit' into feat/features-batch-3
antosubash Oct 22, 2026
5d1f577
fix(sharing): keep grantees and user labels inside the active tenant …
antosubash Oct 22, 2026
fc43fdd
Merge branch 'slice/b3-e-resources' into feat/features-batch-3
antosubash Oct 22, 2026
83fe8d1
Merge origin/main into feat/features-batch-3
antosubash Oct 22, 2026
12e4870
refactor(optimize): drop dead head_markup, share pk formatting, defau…
antosubash Oct 22, 2026
b338e8c
fix(db): forward execute params to the bulk-audit prefetch; audit pla…
antosubash Oct 22, 2026
e77cb90
fix(head,sharing): drop blank head titles; distinct 403 detail for sh…
antosubash Oct 22, 2026
84a1b26
style: ruff format
antosubash Oct 22, 2026
b554ead
Merge remote-tracking branch 'origin/main' into feat/features-batch-3
antosubash Oct 22, 2026
80e5701
Keep _phase_helpers.py under the 300-line cap after merging main
antosubash Oct 22, 2026
b5868a6
test(cli): read add-worker errors without terminal styling
antosubash Oct 22, 2026
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 .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ jobs:
- simple_module_site_lock
- simple_module_tenants
- simple_module_users
- simple_module_sharing
environment:
name: pypi
url: https://pypi.org/project/${{ matrix.package }}/
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ All notable changes to this project are documented in this file. The format is b
- `host/client_app` no longer re-declares `@simple-module-py/ui`'s runtime dependencies, nor `@base-ui/react`, `@radix-ui/react-slot`, `autoprefixer` or `postcss` (nothing loaded them) (#521).

### Added
- Resource-scoped access (#526): `simple_module_core.resources` (`ResourcePolicy` with `filter`/`can`, `ResourcePrincipal`, `ResourceType`, `ResourceTypeRegistry`) and a new `register_resource_types` hook (read at `app.state.sm.resource_types`); `simple_module_db.resource_policy.OwnedResourcePolicy` (owner OR public-for-view OR an injected `share_exists`, with a keyword-required bypass permission) so lists and `paginate()` counts are filtered in SQL; `simple_module_hosting.resource_deps.RequiresResource(policy, action, loader, permission=None)` loads rows only through the policy: invisible → 404, visible but not allowed → 403, and admits API tokens only when `permission=` is set. See `docs/framework/resource-access.md`.
- New optional `sharing` module (`simple_module_sharing`, #526): tenant-scoped `sharing_resource_share` table (migration `f733c7938fd1`, a new root and head labelled `sharing`), `share_exists_for(...)` for `OwnedResourcePolicy`, grant/revoke/list API under `/api/sharing/shares` (validates the resource type, the granter's `share` right on the row and the grantee via the user directory, which on a multi-tenant app must be a member of the active tenant: same error as an unknown id, and labels only for members; new core seam `simple_module_core.tenancy.tenant_member_ids` / `app.state.tenant_members`, published by `tenants`; expiry checked in SQL; revoke hard-deletes) and an admin page at `/admin/sharing/` (en + de). **Release note:** register a PyPI pending publisher for `simple_module_sharing` before the next release.
- Server-rendered `<head>` for Inertia pages (#535 step 1): `inertia.render(..., head=Head(title, description, canonical, robots, og, twitter, json_ld))` puts the tags in the first response so crawlers and link unfurlers see them without JS; they are also sent as the `_head` prop and re-emitted on client visits via Inertia's `serverHead` (`@simple-module-py/ui/lib/server-head`). URL fields are http(s)-only, JSON-LD cannot close its `<script>`. New `public_base_url` host setting (DB-backed, read per request, no restart) builds canonical / `og:url`; the request `Host` is never used. Both `index.html` templates updated. Body SSR remains open in #535.
- `smpy docker add-worker` generates dedicated Python worker images with validated apt packages, optional extras, queue/module routing, and idempotent Compose includes (#540).
- Statement DML is audited (#557): `simple_module_db.bulk_audit` audits ORM bulk
`update()` / `delete()` / `insert()` and Core DML on `Model.__table__` through
the engine's audit sink, one `bulk_updated` / `bulk_deleted` /
`bulk_soft_deleted` / `bulk_restored` entry per row (ids prefetched with the statement's `WHERE`
and tenant scope) up to 500 rows, else one `"*"` summary; `bulk_inserted` is one
summary per statement. `execution_options(sm_audit=False)` opts out;
`record_bulk_audit(session, Model, action, ids, changes)` records `text()`
writes; the new `audit_log` setting `refuse_unaudited_dml` (default off) makes
the opt-out raise `UnauditedWriteError`. No migration.
- Legacy SPA mounts (#541): `ModuleBase.spa_mounts()` returns `SpaMount(prefix, dist_dir, index="index.html", auth="required"|"public")` entries served with an `index.html` deep-link fallback (extension-less paths accepting HTML only; a missing asset stays a 404; hashed assets `immutable`; GET/HEAD only). `auth="public"` exempts GET/HEAD from `AuthMiddleware`. A prefix overlapping a router, static mount or another SPA fails boot (`SM032`). The Inertia client leaves these prefixes to the browser: `startSpaLinkInterception({ exclude })` plus the framework shared prop `nativePrefixes`, which also carries the new DB-backed host setting `native_link_prefixes` (no migration).
- `background_tasks`: opt-in retained logs. With `log_sink_enabled` (default off;
`log_sink_level` default `INFO`, `log_retention_days` default 7) web and worker
log lines are kept in the new `background_tasks_log_record` table, batched off
Expand Down Expand Up @@ -130,6 +144,11 @@ All notable changes to this project are documented in this file. The format is b
it, which removes the `users` ↔ `audit_log` import cycle.

### Changed
- **Behaviour change:** bulk and Core DML writes on audited models now produce
audit entries (actions `bulk_*`), in requests and Celery workers. Statements
that set only excluded columns (the outbox, `TaskExecution.heartbeat_at`) are
still not recorded. Opt a statement out with
`execution_options(sm_audit=False)` (#557).
- **Behaviour change:** `simple_module_db` no longer emits the `entity.created` / `entity.updated` /
`entity.soft_deleted` log lines for models flagged `__audit_exclude__ = True` (`AuditEntry`,
`OutboxMessage`, `RetainedLog`), so retained logs cannot re-log their own writes (#537).
Expand Down
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,11 @@ every Tailwind utility, so keep tokens in `theme.css` and rules in `styles.css`
`register_rate_limits`,
`register_csp_sources`,
`register_design_packs`,
`register_audit_links`, `register_exception_handlers`, `register_middleware`,
`register_audit_links`, `register_resource_types`, `register_exception_handlers`, `register_middleware`,
`register_routes(api_router, view_router)`, `register_admin_routes(admin_router)`
(only with `ModuleMeta.admin_view_prefix`), then async `on_startup` /
`on_shutdown` (reverse order). Non-hook overrides: `locale_dirs()`,
`db_extensions()`, `template_dirs()`, `static_mounts()`.
`db_extensions()`, `template_dirs()`, `static_mounts()`, `spa_mounts()`.

- A module gets one view router per prefix. All-admin modules point
`view_prefix` at `/admin/<name>`; modules with both public and admin pages set
Expand Down Expand Up @@ -305,10 +305,11 @@ development and testing (`session_cookie_secure`).
- **Migrations** live in `host/migrations/versions/`, never in module packages.
`host/migrations/env.py` builds metadata from every installed module and runs
`ensure_db_extensions` first. The history is **not** one linear chain: it has
three roots (`77162e7b184b` initial schema, `e36ab09c4a92` tenants,
`d263ea727663` the framework's `sm_lease`) and five heads (`audit_log`,
`tenants`, `keycloak`, `leases`, and the users trunk, which Alembic reports
as `api_tokens` and, since #536, `background_tasks`). Apply with `upgrade heads` (what `make migrate`
four roots (`77162e7b184b` initial schema, `e36ab09c4a92` tenants,
`f733c7938fd1` sharing, `d263ea727663` the framework's `sm_lease`) and six
heads (`audit_log`, `tenants`, `keycloak`, `sharing`, `leases`, and the users
trunk, which Alembic reports as `api_tokens` and, since #536,
`background_tasks`). Apply with `upgrade heads` (what `make migrate`
does); plain `revision --autogenerate` refuses with "Multiple heads", so name
the branch:

Expand Down
3 changes: 3 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,11 @@ export default defineConfig({
{ text: "Middleware pipeline", link: "/framework/middleware" },
{ text: "Settings & app.state", link: "/framework/settings" },
{ text: "Permissions", link: "/framework/permissions" },
{ text: "Resource-scoped access", link: "/framework/resource-access" },
{ text: "Principal resolvers", link: "/framework/principal-resolvers" },
{ text: "User directory", link: "/framework/user-directory" },
{ text: "Public routes", link: "/framework/public-routes" },
{ text: "Legacy SPA mounts", link: "/framework/spa-mounts" },
{ text: "CORS", link: "/framework/cors" },
{ text: "Deprecations", link: "/framework/deprecations" },
{ text: "Events", link: "/framework/events" },
Expand Down Expand Up @@ -197,6 +199,7 @@ export default defineConfig({
{ text: "audit_log", link: "/modules/audit_log" },
{ text: "site_lock", link: "/modules/site_lock" },
{ text: "mail", link: "/modules/mail" },
{ text: "sharing", link: "/modules/sharing" },
{ text: "tenants", link: "/modules/tenants" },
{ text: "dashboard", link: "/modules/dashboard" },
],
Expand Down
4 changes: 3 additions & 1 deletion docs/database/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The history is **not** a single linear chain. It has several roots and several h
|---|---|
| `77162e7b184b` initial schema | the users trunk (also holds file_storage, background_tasks, api_tokens revisions) |
| `e36ab09c4a92` tenants initial schema | tenants (`f1a9c3d7b2e4` head) |
| `f733c7938fd1` sharing resource share | sharing (root and head) |
| `d263ea727663` framework `sm_lease` table | leases (its own head; [leases](../framework/leases.md)) |

| Head | Owning module | Label Alembic reports |
Expand All @@ -25,14 +26,15 @@ The history is **not** a single linear chain. It has several roots and several h
| `f1a9c3d7b2e4` | tenants | `tenants` |
| `d263ea727663` | framework (`sm_lease`) | `leases` |
| `f6a3b9d2e8c4` | keycloak | `keycloak` |
| `f733c7938fd1` | sharing | `sharing` |

`audit_log` forks off the trunk at `41cf2c53660e`, and `keycloak` forks off the users revision `873ca2015033`.

A module's first migration sets `branch_labels = ("<module_name>",)`. Alembic **inherits** a label onto every descendant, so the label names a revision and everything above it, and it does not isolate the module. It is only a named target (`upgrade <label>@head`, `revision --head <label>@head`) and the source of the Doctor's `module` column, which reads the first label on each revision.

### Known mislabels

Only five of the revisions set `branch_labels` (`audit_log`, `api_tokens`, `keycloak`, `tenants`, `leases`). The graph carries these defects; they are documented rather than rewritten, because changing labels on revisions that installed databases already applied is not worth the risk:
Only six of the revisions set `branch_labels` (`audit_log`, `api_tokens`, `keycloak`, `tenants`, `sharing`, `leases`). The graph carries these defects; they are documented rather than rewritten, because changing labels on revisions that installed databases already applied is not worth the risk:

- `51b2058d8afd_api_tokens_initial` sits in the middle of the trunk, so every revision above it (file_storage, background_tasks including the outbox, and the users revisions through `c8e4f1a2b6d9`) reports the `api_tokens` label. The `api_tokens` head is therefore really a users revision.
- `keycloak` branches off a users revision, so a Keycloak-only install still builds the users schema.
Expand Down
15 changes: 15 additions & 0 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ register_invalidations
register_health_checks
register_public_routes
register_body_limits
register_cors_policies
register_rate_limits
register_csp_sources
register_design_packs
register_audit_links
register_resource_types
register_exception_handlers
register_middleware
register_routes
Expand Down Expand Up @@ -300,6 +302,19 @@ def register_audit_links(self, registry: AuditLinkRegistry) -> None:

A link is a route, not an authorisation: apart from `label_permission`, the registry does not check that the row still exists or that the reader may open it. A link to a deleted record lands on the target screen's own 404, and permissions are enforced by the target route as usual.

## `register_resource_types(registry)`

Declare a resource type whose rows carry object-level access (owner, shares,
public), so cross-cutting code such as the `sharing` module can find its
policy and load a row by id. See [Resource-scoped access](./resource-access.md).

```python
def register_resource_types(self, registry: ResourceTypeRegistry) -> None:
registry.register(
ResourceType(name="notes.note", policy=NOTE_POLICY, select_by_id=select_by_id(Note))
)
```

## `on_startup()` / `on_shutdown()`

Async lifespan hooks that run after all modules are registered.
Expand Down
114 changes: 114 additions & 0 deletions docs/framework/resource-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Resource-scoped access

Permissions are global `<module>.<verb>` strings: they say a principal may edit
*notes*, not *this* note. Resource-scoped access answers the object-level
question ("this user may see this note because they own it, it was shared with
them, or it is public") once per resource type, so detail and list endpoints
enforce the same rule and give the same 404/403 answers (GH #526).

## The pieces

| Piece | Package | Role |
|---|---|---|
| `ResourcePolicy` | `simple_module_core.resources` | Protocol: `filter(principal, action, stmt)` and `can(principal, action, obj)` |
| `ResourcePrincipal` | `simple_module_core.resources` | `user_id` + the request's resolved permission set |
| `ResourceType`, `ResourceTypeRegistry` | `simple_module_core.resources` | What `register_resource_types` declares; read at `app.state.sm.resource_types` |
| `OwnedResourcePolicy`, `select_by_id`, `load_authorized` | `simple_module_db.resource_policy` | The stock owner / public / shared policy |
| `RequiresResource`, `resource_principal` | `simple_module_hosting.resource_deps` | FastAPI dependencies |
| `sharing` module (optional) | `modules/sharing` | A generic `sharing_resource_share` table, grant/revoke API, admin page |

Actions are plain strings; `ACTION_VIEW`, `ACTION_EDIT`, `ACTION_DELETE` and
`ACTION_SHARE` are provided. `view` is special: it decides 404 versus 403.

## `filter` is the authority, `can` is a fast path

`filter` narrows a `SELECT` to the rows the principal may act on. It is a
`WHERE` on your own statement, so `paginate()` counts exactly the rows it lists
and tenant / soft-delete filters still apply. `can` decides for one loaded row
from the facts the row carries (owner, visibility, bypass permission). It may
answer `False` where `filter` would admit the row, because a share lives in
another table. Callers that get `False` from `can` fall back to `filter`, never
the other way round.

## Declaring a policy

```python
from simple_module_core.resources import ResourceType
from simple_module_db.resource_policy import OwnedResourcePolicy, select_by_id

NOTE_POLICY = OwnedResourcePolicy(
Note.owner_id,
bypass_perm=PERM_NOTES_MANAGE, # required: None means nobody bypasses
visibility_col=Note.visibility, # 'public' rows are viewable by anyone
share_exists=share_exists_for(NOTE_TYPE, Note.id), # optional, from `sharing`
)


class NotesModule(ModuleBase):
def register_resource_types(self, registry):
registry.register(
ResourceType(name=NOTE_TYPE, policy=NOTE_POLICY, select_by_id=select_by_id(Note))
)
```

The predicate is `owner_col == user_id OR visibility_col == 'public' (view only)
OR EXISTS(share granting the action)`. A principal holding `bypass_perm`, or the
admin wildcard when a bypass permission is set, sees every row. `bypass_perm` is
keyword-required so the escape hatch is always written down; pass `None` to say
nobody bypasses ownership, not even an admin.

`share_exists` is injected as a `(principal, action) -> EXISTS(...) | None`
factory. The framework never imports the `sharing` module (SM009). A module can
supply its own bespoke share table the same way.

## Detail endpoints: `RequiresResource`

```python
def note_stmt(note_id: uuid.UUID):
return select(Note).where(Note.id == note_id)


@router.patch("/{note_id}")
async def edit(note: Note = Depends(RequiresResource(NOTE_POLICY, ACTION_EDIT, note_stmt))): ...
```

`loader` is an ordinary FastAPI dependency (it may take path and query
parameters) that returns the unfiltered `SELECT`. The row is loaded only through
`policy.filter`, never `session.get` on a raw id:

- not visible (`view`) → **404**, the same answer as a missing row, so ids
cannot be probed;
- visible but the action is not allowed → **403** `PermissionDenied` with
`permission == "resource:<action>"`.

**API tokens.** `RequiresResource` counts as a permission gate for the
[token guard](./permissions.md) only when you pass `permission=`. It then also
enforces that permission, capped at the token's scopes, before loading. Without
`permission=`, an API token is refused on the route like on any route that
names no permission. The policy still applies to tokens on top of the
permission.

## List endpoints

```python
@router.get("/")
async def list_notes(
who: ResourcePrincipal = Depends(resource_principal),
db=Depends(get_db),
params: PageParams = Depends(page_params),
):
stmt = NOTE_POLICY.filter(who, ACTION_VIEW, select(Note).order_by(Note.created_at))
return await paginate(db, stmt, params)
```

A row the principal may not see never leaves the database, and the count
matches the list.

## The optional `sharing` module

Install `simple_module_sharing` for a generic share table instead of a bespoke
one. Shares are tenant-scoped, hard-deleted on revoke, and an expired share is
ignored in SQL. Granting checks that the resource type is registered, that the
granter passes the owning policy's `share` action, and that the grantee exists
in the [user directory](./user-directory.md). See
[the module page](../modules/sharing.md).
66 changes: 66 additions & 0 deletions docs/framework/spa-mounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Legacy SPA mounts

Porting a large app step by step means running its existing client-routed SPA
next to new Inertia pages for a while. A module serves the SPA's build output
under a prefix by overriding `ModuleBase.spa_mounts()`:

```python
from pathlib import Path

from simple_module_core import ModuleBase, SpaMount


class LegacyModule(ModuleBase):
def spa_mounts(self) -> list[SpaMount]:
return [SpaMount("/legacy", Path(__file__).parent / "dist", auth="required")]
```

`SpaMount(prefix, dist_dir, index="index.html", auth="required" | "public")`.
A mount whose `dist_dir/index` does not exist (the SPA was not built) is skipped
with a warning.

## What is served

- `GET`/`HEAD` only; other methods are refused.
- A file that exists is served as is. Content-hashed files (`index-3f9a8c21.js`)
get `Cache-Control: public, max-age=31536000, immutable`.
- A missing path **without an extension** whose request accepts `text/html`
(`/legacy/datasets/123`) is answered with `index.html`, `Cache-Control: no-cache`,
so the SPA's router takes over.
- A missing asset (`/legacy/missing.js`) or a missing path that does not accept
HTML is a plain 404, never HTML.
- No filesystem path is built from the request: lookups go through `StaticFiles`,
paths containing `..` are refused, and the fallback only ever serves the
configured index file.

## Authentication

`auth="required"` (the default) leaves the mount behind `AuthMiddleware`:
an anonymous visitor is redirected to login. `auth="public"` registers the prefix
in the [public route registry](/framework/public-routes) for `GET`/`HEAD` only.
CSP applies as for any response; whitelist external origins the SPA needs with
`register_csp_sources`.

## Boot check

A prefix that overlaps a module's route/view prefix, a `static_mounts()` prefix,
a framework path or another SPA mount fails boot with
[`SM032`](/reference/diagnostic-codes), in every environment.

## Inertia link interception

The Inertia client turns same-origin link clicks into Inertia visits
(`startSpaLinkInterception`). Links into a legacy SPA must stay full navigations,
so the server publishes a shared prop `nativePrefixes`: the mounted SPA prefixes
(with a trailing slash) plus the DB-backed host setting `native_link_prefixes`
(a list such as `["/docs/"]`, editable under Settings, read per request).
`host/client_app/app.tsx` passes them to the interceptor:

```ts
startSpaLinkInterception({ exclude: (url) => isExcluded(url, nativePrefixes) });
```

`exclude` also accepts a prefix string or a list of prefixes. An Inertia
`<Link>` or `router.visit()` pointing into a mount is not covered; use a plain
`<a href>` there. Links inside the SPA are never touched, because the SPA is its
own document without the Inertia client.
Loading
Loading