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 8ef2523
Browse filesBrowse the repository at this point in the historyBrowse files
feat(audit): a package uninstall writes one package_uninstall ledger row about the package itself (#31286)
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiMUyQBfkxjbMPiRuAiJCX6U)_Fixes#31186
Clause-②: yes
Metadata-protocol: none
## 维护者速读
- 改了什么:卸载一个软件包后,审计日志里会多一条
`package_uninstall`(卸载软件包)记录,写明谁卸载的、哪个包、版本、所在环境、是否保留了数据表,以及删掉了几项元数据、有几项没删掉。所有卸载入口(Studio
/ `DELETE /api/v1/packages/:id`、导入回滚、配对定制包安装撤销、cloud
侧直接调用卸载动词的工具)都会写,因为写入点在它们共同经过的卸载动词 `deletePackage` 里。
- 为什么改:此前卸载只留下逐条元数据的删除记录,包本身的那一行(`sys_packages`)是用原始 SQL
删的,不走钩子,所以「这个应用被谁卸载了」在审计日志里没有任何记录;一个没有存储元数据的包卸载后完全不留痕。
- 风险与回滚:只新增一条审计行,卸载的行为和返回内容都不变。审计行在包已删除、清理全部跑完之后才写;写失败只记一条 error
日志,卸载照常成功;没有审计插件的环境完全不受影响。被拒绝的卸载、以及不存在的包(404)都不写。这行不属于任何组织,只有平台管理员能看到。可整个
PR 回滚。
- 先看:`packages/metadata-protocol/src/protocol-package-uninstall.ts` 末尾
`return` 前的调用与
`readInstalledPackage`;`packages/plugins/plugin-audit/src/package-lifecycle-audit.ts`
的 `buildPackageLifecycleAuditRow`;`sys-audit-log.object.ts` 新增的
`package_uninstall` 选项。
- 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run
src/package-lifecycle-audit.test.ts`,预期 9
个用例全部通过,其中第二个用例从真实引擎读回整行,断言操作人、包、版本、环境都在。
- 需要你做的:无,席位落地。
## What changes
An uninstall left one `sys_audit_log` `delete` row per metadata item it
removed (credited to the caller since #31023) and nothing about the
package. `@objectstack/service-package` deletes the package's own
`sys_packages` row with `DELETE FROM sys_packages WHERE id = ?` through
`engine.execute`, which fires no hook, so no writer could ever see it.
Measured at branch start on `main` 169ff7e: the verb's body
(`protocol-package-uninstall.ts`) makes no audit call, and the new verb
and door pins below fail against it (the ablation section is that
measurement).
Per the seat's ruling (an event writer in `plugin-audit` reached through
the `audit` service slot, the #27908 pattern; uninstall only):
- **`@objectstack/plugin-audit`**: new `package-lifecycle-audit.ts`.
`createPackageLifecycleAuditWriter` registers
`recordPackageLifecycleEvent` on the `audit` slot beside
`recordAuthEvent`, `recordActionInvocation` and `recordDataExport`. The
caller hands over an event; this package owns the row:
- `action: 'package_uninstall'`;
- `actor` = the principal the verb was called for; `user_id` = the same
value unless it is a `svc:` principal (no `sys_user` row carries one);
- `object_name` / `record_id` null: `sys_packages` is not an object, so
the row is about no record and the ledger's record gate serves it under
the ledger's own grant (`ledgerRowAboutNoRecordWhere`);
- `tenant_id` null: an uninstall is environment-wide (ADR-0131 D6/D12),
so this is a deployment-level row, served to the platform administrator
alone (#26750 ruling A). Pinned by adding the row to
`audit-log-deployment-level-rows.integration.test.ts` (all three tenancy
postures);
- `metadata`: `package_id`, `version`, `environment_id`, `keep_data`,
`items_removed`, `items_failed`, `outcome` (`complete` / `partial`).
- Never throws; a refused insert is reported at `error` once per cause
through a named callee (`persistPackageLifecycleAuditRow`), declared in
`check-durability-degradation-log-level.mjs` and
`measure-durability-swallow-family.mjs` the way
`persistDataExportAuditRow` is.
- `sys_audit_log.action` gains the `package_uninstall` option,
writer-first, with its label and all ten translated locales; the writer
is registered in `sys-audit-log-retired-actions.test.ts`'s
`ACTIONS_WITH_WRITERS`.
- **`@objectstack/metadata-protocol`**: `deletePackage` calls the slot
once, after the cleanups and before it returns, so every caller of the
verb is covered. Two reads feed it: the installed version, read from the
registry right before the withdrawal releases it (beside
`captureUninstallDeclared`, nothing near `assertPackageUninstallable`
changes), and the protocol's `environmentId`, now handed to the moved
body through its host. A refusal throws before the call, so it writes
nothing; an id the registry did not hold and the store bound no metadata
to writes nothing either (the door answers it `404`). No audit slot
means no call. A slot that throws anyway is logged at `error` and never
changes the answer.
- The tenant-audit census counts the writer's one new insert site
(`content/docs/permissions/tenant-audit-census.mdx` and its counts file,
regenerated with `node scripts/tenant-audit-census.mjs --write` plus the
six hand-written figures the gate holds to it).
No `packages/spec` change.
## Tests
- `packages/plugins/plugin-audit/src/package-lifecycle-audit.test.ts`
(new, 9 cases): on the real `ObjectQLPlugin` + `AuditPlugin` boot,
through the `audit` slot, read back from the engine: the full row; a
partial uninstall; absent actor / version / environment recorded as
null; no row for an event naming no package or another action; a `svc:`
actor never in `user_id`; a ledger without the `actor` column; no
engine, no throw; a refused insert reported once at `error` and never
thrown.
-
`packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts`,
new top-level describe in the existing harness (9 cases): one event with
package, version, environment, actor, `keepData` and counts, handed over
after the cleanups; partial uninstall; CONTROLs: both store refusal
channels, the registry's refusal and a retired request key hand over
nothing; a non-installed id hands over nothing; no slot, the uninstall
completes; a throwing slot does not change the answer.
-
`packages/runtime/src/package-uninstall-store-refusal.integration.test.ts`,
new top-level describe in the existing harness (4 cases, real `ObjectQL`
+ `SqlDriver` + `PackageServicePlugin` + protocol + `HttpDispatcher`):
`DELETE /packages/:id` hands the slot one event naming the signed-in
user, the package, `1.0.0` and the environment; an in-process
`protocol.deletePackage` call hands over the same event naming its
actor; CONTROLs: a store-refused delete (500) and a non-installed id
(404) hand over nothing.
- `audit-log-deployment-level-rows.integration.test.ts`: the
`package_uninstall` row joins the deployment-level fixture rows; served
to the platform administrator, to no organization reader, in all three
postures.
<details><summary>Ablation (one-time,
scripts/ablation-replace.mjs)</summary>
The call in `deletePackage` was replaced by a no-op with the same
arguments (marker `_ablate31186`), `@objectstack/metadata-protocol`
rebuilt, and `ablation-dist-preflight.mjs` confirmed the marker in
`dist/`. Result: `protocol.package-delete-refusal.test.ts` 4 failed / 16
passed (the four positive #31186 cases),
`package-uninstall-store-refusal.integration.test.ts` 2 failed / 9
passed (the HTTP door case and the in-process case). Every CONTROL
stayed green. Restore: `ok restored: blob == HEAD (2b053d2) and git
diff HEAD is empty`, rebuilt, preflight `--absent` exit 0, both files
green again (20/20, 11/11).
</details>
<details><summary>Package suites and typecheck</summary>
- `pnpm --filter @objectstack/plugin-audit test`: Test Files 77 passed,
Tests 1212 passed.
- `pnpm --filter @objectstack/metadata-protocol test`: Test Files 324
passed / 4 skipped, Tests 22545 passed / 26 skipped.
- `pnpm --filter @objectstack/runtime exec vitest run
src/package-uninstall-store-refusal.integration.test.ts`: 11 passed
(only this runtime file changed; the runtime suite runs in CI).
- `pnpm --filter <pkg> typecheck` for plugin-audit, metadata-protocol
and runtime: exit 0 each.
</details>
## Acceptance notes
- **Uncovered path, declared:** install-local's uninstall door
(`handleUninstall` in
`packages/cloud-connection/src/marketplace-install-local-plugin.ts`)
does not go through `deletePackage`: it removes its marketplace ledger
entry, withdraws the package from the registry and calls
`protocol.runUninstallCleanups` directly, so it writes no
`package_uninstall` row. Per the seat's ruling no second call site is
added here; the seat files it.
- Install, enable and disable would fit the same writer (a closed
`PackageLifecycleAuditAction` union and the same event shape); none is
built.
- The cleanup-registration route (`registerUninstallCleanup`) was
weighed and not taken (seat ruling A): cleanups run for every id
including a non-installed one, run after the registry released the
version, carry no environment or `keepData`, and their outcomes ride on
the `DELETE` answer's `cleanups[]`.
## Verification
Measured on head `99f0fba08` (branch merged with `origin/main`
ea62933):
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 119 gate commands derived for this change
set; all 119 run, every one `exit 0`; `--ran` reconciliation: 119
derived, 119 run, 0 NOT-MEASURED, 0 UNRUN.
- The package suites, the three typechecks and `eslint
--no-inline-config` over the 25 touched `.ts` / `.mjs` files, each `exit
0`, on the same head.
- The full command list with exit codes is in the os-dev-report comment
on #31186.
Session: https://claude.ai/code/session_017ihL2bwU56eMCkj69h7Gd8
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
feat(audit): a package uninstall writes one `package_uninstall` row about the package itself
7
+
8
+
Clause-②: yes
9
+
Metadata-protocol: none
10
+
11
+
Uninstalling a package left a `sys_audit_log` row for each metadata item it removed, and nothing about the package: its `sys_packages` row is deleted with raw SQL, so no hook ever saw it. A package that shipped no stored metadata left no trace at all. The uninstall verb (`deletePackage`, the path `DELETE /api/v1/packages/:id`, the import rollback, the paired-customization undo and any in-process caller take) now writes one row per package it uninstalled, after the package's stored row is gone: `action: "package_uninstall"`, the actor the verb was called for (`user_id` and `actor`; a `svc:` principal is the `actor` only), no record and no organization (an uninstall is environment-wide, so the row is deployment-level and served to the platform administrator alone), and in `metadata` the package id, its installed version, the environment, whether the object tables were kept, the items removed and left behind, and `outcome` (`complete` or `partial`).
12
+
13
+
An uninstall the verb refuses writes no row, and neither does an id that is not installed. The writer lives on the `audit` service slot as `recordPackageLifecycleEvent`, beside `recordAuthEvent`; a kernel without the audit plugin uninstalls exactly as before, and a ledger write that fails is logged at `error` once per cause without changing the uninstall's answer.
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+16-16Lines changed: 16 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132
132
133
133
The same holds twice over for the context. An options argument spelled as a
134
134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135
-
forwarding shim cannot, and **56 of the 269 sites are spelled that way**. A
135
+
forwarding shim cannot, and **56 of the 270 sites are spelled that way**. A
136
136
context resolved from an inline literal or a local `const` can be tested for
137
137
`isSystem`; one arriving from a helper call cannot.
138
138
@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197
197
198
198
| carried figure | where it survives | this census |
199
199
| :--- | :--- | ---: |
200
-
| 175 write call sites | quoted in the merged changeset |**269**|
200
+
| 175 write call sites | quoted in the merged changeset |**270**|
201
201
| 24 carrying no tenant context | quoted in the merged changeset |**2** provable and tenancy-enabled; **30** more whose options argument is unreadable |
202
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**191 of 269** decidable, **78** undecidable |
203
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 149 decidably elevated, 0 decidably not, 112 undecidable |
202
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**192 of 270** decidable, **78** undecidable |
203
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 150 decidably elevated, 0 decidably not, 112 undecidable |
204
204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205
205
206
206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217
217
218
218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219
219
figure has no surviving corroboration anywhere in the tree.** This census reads
220
-
149 of 269 (55%) as decidably elevated, with 112 more whose elevation is a
220
+
150 of 270 (56%) as decidably elevated, with 112 more whose elevation is a
221
221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222
222
answer is that a static reading cannot settle it.
223
223
224
-
⇒ **Cite `2 / 269`, and say what it is**: the sites whose options argument was
224
+
⇒ **Cite `2 / 270`, and say what it is**: the sites whose options argument was
225
225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226
226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227
227
without tenant context" — **30 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233
233
234
234
| what | count |
235
235
| :--- | ---: |
236
-
| write call sites on the application surface |**269**|
237
-
| …whose object name is statically decidable |191|
236
+
| write call sites on the application surface |**270**|
237
+
| …whose object name is statically decidable |192|
238
238
| …whose object name is chosen at run time | 78 |
239
-
| …against an object with tenancy ENABLED |185|
239
+
| …against an object with tenancy ENABLED |186|
240
240
| …against an object that declares tenancy off | 6 |
241
-
| threading a tenant context |205|
241
+
| threading a tenant context |206|
242
242
| PROVABLY carrying none (options read, no context key) |**8**|
243
243
| …of those, against a decidably tenancy-enabled object |**2**|
244
244
| options argument UNREADABLE — may or may not carry one | 56 |
245
245
| …of those, against a decidably tenancy-enabled object | 30 |
246
-
| threading a decidably ELEVATED (`isSystem`) context |149|
246
+
| threading a decidably ELEVATED (`isSystem`) context |150|
247
247
| threading a context that is decidably NOT elevated | 0 |
248
248
| threading a context whose elevation is a run-time fact | 112 |
249
249
250
250
| how the instrument reached the site | count |
251
251
| :--- | ---: |
252
-
| receiver carried a readable engine type |205|
252
+
| receiver carried a readable engine type |206|
253
253
| receiver erased, placed by the object NAME | 54 |
254
254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255
255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256
256
257
-
| object name spelled inline |122|
257
+
| object name spelled inline |123|
258
258
| object name spelled through a `const`| 69 |
259
259
| object name is an `object: string` parameter | 7 |
260
260
| object name is some other run-time expression | 71 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307
307
their values are not compared. The reasoning, and the measurement behind it,
0 commit comments