You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0cf35e4
Browse filesBrowse the repository at this point in the historyBrowse files
fix(audit_log): gate entity labels on the owning module's permission
The audit table stopped showing bare uuids by asking each module to name its
own rows, and the users module answers with `full_name or email`. Nothing
checked whether the reader was allowed that answer, so `audit_log.view` on its
own became a second, unguarded read of the user directory: page the log and
collect the display name of every account that has ever been edited — and, for
any account still holding an unaccepted invite, its email address, because
those have no `full_name` yet and the resolver falls through to the email. The
CSV export had the same hole in its `entity_label` column, and the users edit
page's activity card resolved through the same helper.
Naming a row is a read of that row, so it now takes that row's permission.
`AuditLink` gains `label_permission`; `resolve_entity_labels` takes the
requesting principal's grants and skips — before dispatch, so the resolver
never even queries — any entity type whose owner declared a permission the
reader lacks. `users` declares `users.manage`, the same permission that opens
the user list at the front door. Everything else is left ungated by default: a
setting key, a filename, a task name and a flag name are what changed, not who
somebody is, and withholding them would blind a legitimate auditor for nothing.
The entry itself is never withheld. A reader without `users.manage` still sees
that a `User` was updated, when, by whom, which id and which fields — that is
the audit trail, and an auditor who cannot see it is not an auditor.
The actor column stays ungated, and that is the deliberate half of the answer
the issue asks for: an audit trail that will not say who acted is not one. The
two columns disclose the same field and answer different questions — the entity
column names people who were merely *edited*. `docs/modules/audit_log.md` now
says so outright under § Entity and actor names, so `audit_log.view` is granted
knowing what it discloses.
Supporting changes:
- `simple_module_core.permissions.grants()` — one place for the wildcard rule,
so gating part of a *response* reads a grant the way `RequiresPermission`
reads it at the door. A hand-rolled `in` check forgets that `admin` holds `*`
and no named permission at all; `RequiresPermission` now routes through it.
- `simple_module_hosting.permissions.resolved_permissions_for(request)` —
extracted from `RequiresPermission.__call__`, which already had this
middleware-cache-or-resolve dance inline.
- `Browse.tsx` was 286 lines against the 300-line cap, so the table and the
pager move to `components/EntriesTable.tsx` and `components/Pager.tsx`. Pure
extraction, no behaviour change — it is now 168 lines, and the next change to
this screen is a change rather than a forced split.
Known adjacent surface, deliberately not addressed here: the `changes` column
records the values that were written, so a `User` create entry still contains
the email that was set. That is the audit trail doing its job rather than a
label leaking, it needs its own decision about redaction versus
`__audit_exclude_fields__`, and folding it into this fix would have changed
what the log records rather than who may read a name.
Closes#300
`label_key` translates the label the same way `MenuItem.label_key` does, falling back to `label` when the key resolves to nothing. Rows are rendered server-side, so the audit view translates these before they reach the page.
245
245
246
-
The registry maps class names to URL templates and nothing more: it 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.
246
+
`label_resolver` names individual rows — a batch callable taking the request session and every id of that type on the page, returning `{id: display name}`. Only the owning module knows that a `Setting` is named by its key and a `User` by `full_name or email`, and without one the reader gets a bare primary key.
247
+
248
+
`label_permission` gates that name. **Set it whenever naming the row discloses something the module gates elsewhere.** The name travels inside the audit payload, so no downstream route ever gets the chance to refuse it — a reader holding `audit_log.view` and nothing else would otherwise read the display name (and, for accounts with an outstanding invite, the email) of every account that has ever been edited. `users` therefore declares `users.manage`. Readers without it still see the entry, the action and the id; they just do not get the name. Leaving it empty — the default — says the name is safe for anyone who may read the trail at all, which is true of setting keys, filenames and task names.
249
+
250
+
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.
-**Namespace with the module name.**`orders.create`, not `create_order`.
152
152
-**Use `<module>.<verb>` form.** Nouns like `orders.admin` are fine for broad grants.
153
-
-**Don't inline string checks.**`if "orders.create" in principal.permissions: ...` scattered through code is hard to audit. Use `RequiresPermission` or refactor into a dependency.
153
+
-**Don't inline string checks.**`if "orders.create" in principal.permissions: ...` scattered through code is hard to audit. Use `RequiresPermission` or refactor into a dependency. When the thing to gate is part of a *response* rather than a route — a column, a label, a card — read the caller's grants with `resolved_permissions_for(request)` and test them with `simple_module_core.permissions.grants`, which is where the wildcard rule lives. A hand-rolled `in` check forgets that `admin` holds `*` and no named permission at all.
154
154
-**Don't use roles in business logic.** Check permissions, not `user.roles`. Roles are an admin concept; permissions are the enforcement boundary.
Copy file name to clipboardExpand all lines: docs/modules/audit_log.md
+13-1Lines changed: 13 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -108,14 +108,26 @@ from audit_log.contracts.schemas import AuditEntryRead, AuditEntryList
108
108
109
109
The table itself carries `__audit_exclude__ = True` so audit writes never re-enter the capture loop.
110
110
111
+
## Entity and actor names
112
+
113
+
An entry stores a model class name and a primary key, which proves what happened and names nobody. The browse screen, the CSV export and the users edit page's activity card all resolve those ids at render time through the audit-link registry: each module supplies a batch `label_resolver` naming its own rows, one query per entity type per page. Nothing is stored — the row keeps the ids it recorded.
114
+
115
+
**The entity column's names are permission-gated.** An `AuditLink` may declare a `label_permission`, and a reader who does not hold it gets the id instead of the name. `users` declares `users.manage`, because a user row is named by `full_name or email` — so without the gate, `audit_log.view` alone was a way to read the display name of every account that appears in the log, and the email address of every account still holding an unaccepted invite (those have no `full_name` yet). The entry itself is never withheld: the action, the type, the timestamp and the id are the audit trail. Links whose owner declares nothing — setting keys, filenames, task names, flag names — are shown to anyone who may read the trail.
116
+
117
+
**The actor column's names are not.**`audit_log.view` does imply "may see who acted", and that is deliberate: an audit trail that will not say who performed a change is not one. The two columns disclose the same field and answer different questions — the entity column names people who were merely *edited*, which reviewing the trail does not require. Grant `audit_log.view` knowing it discloses the display name (or email) of everyone who has ever made a change.
118
+
119
+
The `changes` column is a third surface, and a wider one: it records the values that were written, so a `User` create entry contains the email that was set. Use `__audit_exclude_fields__` on a model to keep a column out of the diff (see § Opting out).
120
+
111
121
## Permissions
112
122
113
123
| Code | Purpose |
114
124
|---|---|
115
-
|`audit_log.view`| read the audit trail (API + browse page)|
125
+
|`audit_log.view`| read the audit trail (API + browse page + CSV export), including the name of every account that has *acted*|
116
126
117
127
There is no write permission — the trail is append-only and written by the framework, never edited through the UI.
118
128
129
+
`audit_log.view` does **not** confer the name of an account that merely appears as the *subject* of an entry — that takes `users.manage`, the same permission that opens the user list. See § Entity and actor names.
Copy file name to clipboardExpand all lines: docs/modules/users.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -207,6 +207,8 @@ Everything else is DB-backed (initial values are pydantic defaults; edit under U
207
207
|`users.manage`| admin: list / invite / disable / role-assign |
208
208
|`users.self.profile`| edit own profile (granted to `user` role) |
209
209
210
+
`users.manage` also unlocks account *names* in the audit log's entity column: the module registers its audit link with `label_permission="users.manage"`, so a reader holding only `audit_log.view` sees the id of an edited account rather than its display name or email. See [audit_log](/modules/audit_log) § Entity and actor names.
211
+
210
212
## Menu
211
213
212
214
| Label | URL | Icon | Section | Group | Order | Roles |
0 commit comments