diff --git a/.changeset/system-overview-permission-change-tile-removed.md b/.changeset/system-overview-permission-change-tile-removed.md new file mode 100644 index 0000000000..4b71635101 --- /dev/null +++ b/.changeset/system-overview-permission-change-tile-removed.md @@ -0,0 +1,61 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675) + + + +The System Overview dashboard shipped a "Permission Changes" metric tile +filtering `sys_audit_log.action = 'permission_change'`. **The tile could never +report anything but `0`, on any deployment that has ever existed** — the value +had no writer anywhere in the repo. There are exactly two `sys_audit_log` +writers: `plugin-audit`'s generic hook writer, whose `actionFor` maps +afterInsert/afterUpdate/afterDelete to `create`/`update`/`delete` and nothing +else, and `plugin-auth`'s admin user-import. Neither has ever emitted +`permission_change`. #8147 then retired the value from the action enum outright, +so the tile's filter now names a value the platform does not even declare. + +**An empty tile on a compliance surface is worse than a missing one.** A +permanently-`0` "Permission Changes" count does not read as "this platform does +not track permission changes" — it reads as a *negative finding*: an auditor +concludes the platform watched for permission changes over the selected window +and found none. The number was live and the query was real; the question it +answered was one no row could ever be an answer to. 审计面宁窄勿谎 — a narrow +audit surface beats a lying one. + +**Removed rather than refiltered onto a live action.** Permission and role edits +*are* captured today, as ordinary `create` / `update` rows written by the generic +hook against the permission objects — so the honest lens on them is `object_name` +on the audit list view, a row-level question rather than a single-number KPI. +Approximating one as a tile would have put a second not-quite-true number on the +same board. The two surviving Row 2 tiles ("Login Events", "Config Changes") +split the 12-column row in half instead of leaving a gap where the removed tile +sat. + +The by-action tile's description stops naming `permission` among its example +actions, in the source **and in all four locale bundles** — the translations are +the strings actually served, so correcting only the source would not have reached +a single user. + +⚠️ **`import` is deliberately untouched.** It was named in the same ruling as +`permission_change`, but its retirement premise was falsified during #8147: it +has a live writer (`plugin-auth`'s admin user-import writes a run-level row) and +a shipped list view that filters it. Removing it from the dashboard while the +platform still emits it would produce the exact inverse defect — an audit action +that can be written but cannot be found. + +Both directions are pinned. A tombstone refuses any board widget filtering a +retired action value, with a live-action control so it cannot pass on a board +that has no widgets or whose predicates moved. The app/dashboard translation +parity test gains the **reverse direction it was missing** for dashboard widgets +— it asserted every declared widget has a translation, but nothing stopped a +translation outliving its widget, which is precisely what these four locale +entries would have done. diff --git a/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts b/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts index 25b0c8d354..4821b8c52e 100644 --- a/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts +++ b/packages/platform-objects/src/apps/dashboards/system-overview-tile-semantics.test.ts @@ -238,7 +238,6 @@ describe('the dashboard filter the Row 1 inventory tiles opt out of', () => { it('still reaches every audit widget', () => { const auditWidgets = [ 'widget_login_events', - 'widget_permission_changes', 'widget_config_changes', 'widget_events_by_type', 'widget_events_by_user', @@ -250,6 +249,64 @@ describe('the dashboard filter the Row 1 inventory tiles opt out of', () => { }); }); +// ── Tombstone: no tile filters on a retired action value ──────────────────── +// +// The board carried a "Permission Changes" tile filtering +// `action: 'permission_change'` for its whole life. Nothing ever wrote that +// value — the only two `sys_audit_log` writers are plugin-audit's generic hook +// (`actionFor` maps afterInsert/Update/Delete to create/update/delete and +// nothing else) and plugin-auth's admin user-import — so the tile reported `0` +// on every deployment that has ever existed, and the value was then retired +// from the enum outright. `export` retired alongside it. +// +// This is the same defect class as the rest of this file, one level up: not "the +// query answers a different question from the label" but "the query can answer +// nothing at all, under a label that implies it did". On a COMPLIANCE board the +// empty tile is the more dangerous of the two — "Permission Changes: 0" reads as +// a negative finding, not as an absent feature. +// +// ⚠️ `import` is NOT on this list and must not be added. It was named in the +// same ruling but survives with a live writer (plugin-auth's admin user-import +// writes a run-level row) and a shipped list view that filters it. Retiring it +// from the UI while the platform still emits it would produce the inverse defect +// — an action that can be written but not found. +// +// Why hard-coded rather than diffed against the enum: `sys_audit_log` lives in +// `@objectstack/plugin-audit`, which this package does not depend on (the audit +// objects moved OUT of here under ADR-0029 K2/D8) — and it must not start +// depending on it for a test. Hard-coded ids checked one by one is the same +// disposition `setup-nav-dead-key-tombstone.test.ts` records for the same +// reason. +describe('retired `sys_audit_log.action` values are gone from the board', () => { + const RETIRED_ACTIONS = ['permission_change', 'export']; + + const actionFilterOf = (w: { filter?: FilterCondition }): unknown => + (w.filter as Record | undefined)?.action; + + it('no widget filters on one', () => { + const offenders = (board.widgets ?? []) + .filter((w) => RETIRED_ACTIONS.includes(String(actionFilterOf(w)))) + .map((w) => `${w.id} → action=${String(actionFilterOf(w))}`); + expect(offenders, 'widgets filtering a retired audit action').toEqual([]); + }); + + it('and the removed tile itself is not back', () => { + expect((board.widgets ?? []).map((w) => w.id)).not.toContain('widget_permission_changes'); + }); + + // Opposite direction. Both assertions above also pass on a board with no + // widgets, or if `filter.action` stopped being where a tile's action + // predicate lives — in which case they would be pinning nothing at all. A + // LIVE action filter must still be visible through exactly the same read. + it('opposite direction — a live action filter is still found by the same read', () => { + const live = (board.widgets ?? []) + .map((w) => actionFilterOf(w)) + .filter((a): a is string => typeof a === 'string'); + expect(live, 'the board still filters on live actions').toContain('login'); + expect(live).toContain('config_change'); + }); +}); + // ── Tile 1: "Total Users" ─────────────────────────────────────────────────── describe('widget_total_users — "Total" means total', () => { diff --git a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts index ae0ba41767..006616e43e 100644 --- a/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts +++ b/packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts @@ -13,7 +13,7 @@ import { Dashboard } from '@objectstack/spec/ui'; * * Layout (4 rows on a 12-col grid): * 1. Platform KPIs — users / orgs / sessions / packages - * 2. Security KPIs — login / permission / config audit counts + * 2. Security KPIs — login / config audit counts * 3. Distribution charts — audit events by action + by user * 4. Recent audit events table * @@ -134,32 +134,45 @@ export const SystemOverviewDashboard = Dashboard.create({ // successful logins (both fold into `action='login'`). Surfacing a // total Login Events count is honest; a "Failed Logins" widget will // need a richer enum or a separate detail field first. + // + // This row carried a THIRD tile, "Permission Changes", filtering + // `action: 'permission_change'`. It is gone, and no replacement tile takes + // its place. The value had no writer anywhere in the repo — the only two + // `sys_audit_log` writers are plugin-audit's generic hook (whose `actionFor` + // maps afterInsert/Update/Delete to create/update/delete and nothing else) + // and plugin-auth's admin user-import — so the tile read `0` on every + // deployment that has ever existed, and then its action value was retired + // from the enum outright, leaving a filter no row can ever match. An empty + // widget on a COMPLIANCE surface is worse than a missing one: an auditor + // reading "Permission Changes: 0" concludes the platform watched for them + // and found none, which is false. 审计面宁窄勿谎 — a narrow audit surface + // beats a lying one. + // + // Not replaced by a refiltered tile, deliberately: permission and role + // edits ARE captured, as ordinary `create`/`update` rows on the permission + // objects written by the generic hook, so the honest lens on them is + // `object_name` on the audit list view — a row-level question, not a + // single-number KPI. Inventing a tile that approximates it here would put + // a second not-quite-true number on the same board. + // + // The two survivors split the 12-col row in half (the Row 3 shape) rather + // than leaving a 4-col hole where the removed tile sat. { id: 'widget_login_events', dataset: 'sys_audit_log_metrics', values: ['event_count'], title: 'Login Events', type: 'metric', - layout: { x: 0, y: 2, w: 4, h: 2 }, + layout: { x: 0, y: 2, w: 6, h: 2 }, filter: { action: 'login' }, colorVariant: 'blue', description: 'Authentication events recorded by the audit log', }, - { - id: 'widget_permission_changes', - dataset: 'sys_audit_log_metrics', values: ['event_count'], - title: 'Permission Changes', - type: 'metric', - layout: { x: 4, y: 2, w: 4, h: 2 }, - filter: { action: 'permission_change' }, - colorVariant: 'warning', - description: 'Recent permission and role modifications', - }, { id: 'widget_config_changes', dataset: 'sys_audit_log_metrics', values: ['event_count'], title: 'Config Changes', type: 'metric', - layout: { x: 8, y: 2, w: 4, h: 2 }, + layout: { x: 6, y: 2, w: 6, h: 2 }, filter: { action: 'config_change' }, colorVariant: 'blue', description: 'System configuration modifications', @@ -195,7 +208,10 @@ export const SystemOverviewDashboard = Dashboard.create({ { id: 'widget_recent_events', title: 'Audit Events by Action', - description: 'Event volume grouped by action (login, permission, config, …)', + // The example actions named here have to be actions the platform can + // actually emit — this string used to lead with `permission`, which + // advertised the retired value from a second place on the same board. + description: 'Event volume grouped by action (login, logout, config, …)', type: 'table', dataset: 'sys_audit_log_metrics', dimensions: ['action'], diff --git a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts index 5af3592a5c..72ad265492 100644 --- a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts +++ b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts @@ -111,4 +111,27 @@ describe('dashboard widgets are translated in every locale', () => { expect(missing, `untranslated widget titles in dashboards.${dashboard.name}`).toEqual([]); }); } + + // The reverse direction, for the same reason it exists for Studio's nav above: + // a translation for a widget the board no longer declares is dead weight that + // reads as coverage. This half was missing, and a removal proved why — when + // `widget_permission_changes` was deleted from the board, its title and + // description stayed behind in all four locales and every gate in this package + // was green. A dashboard CAN be walked statically (unlike Setup, which is + // composed at runtime — see `setup-nav-dead-key-tombstone.test.ts`), so there + // is nothing here to stop the general claim being made. + for (const [locale, data] of Object.entries(LOCALES)) { + it(`${dashboard.name} — ${locale} carries no translation for a removed widget`, () => { + const declared = new Set( + (dashboard.widgets ?? []).map((w) => w.id).filter((id): id is string => !!id), + ); + const translated = Object.keys( + (data.dashboards?.[dashboard.name]?.widgets ?? {}) as Record, + ); + expect( + translated.filter((id) => !declared.has(id)), + `dashboards.${dashboard.name}.widgets keys with no declaring widget`, + ).toEqual([]); + }); + } }); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 76ebcd17e9..76096632a1 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -211,10 +211,6 @@ export const en: TranslationData = { title: 'Login Events', description: 'Authentication events recorded by the audit log', }, - widget_permission_changes: { - title: 'Permission Changes', - description: 'Recent permission and role modifications', - }, widget_config_changes: { title: 'Config Changes', description: 'System configuration modifications', @@ -229,7 +225,7 @@ export const en: TranslationData = { }, widget_recent_events: { title: 'Recent Audit Events', - description: 'Latest platform events (login, permission, config, …)', + description: 'Latest platform events (login, logout, config, …)', }, }, }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index add5867851..cdfd3c5cb8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -148,11 +148,10 @@ export const esES: TranslationData = { widget_active_sessions: { title: 'Sesiones Activas', description: 'Número de sesiones de usuario activas en este momento' }, widget_packages_installed: { title: 'Paquetes Instalados', description: 'Instalaciones de paquetes activas en los proyectos' }, widget_login_events: { title: 'Eventos de Inicio de Sesión', description: 'Eventos de autenticación registrados por el log de auditoría' }, - widget_permission_changes: { title: 'Cambios de Permisos', description: 'Modificaciones recientes de permisos y roles' }, widget_config_changes: { title: 'Cambios de Configuración', description: 'Modificaciones de configuración del sistema' }, widget_events_by_type: { title: 'Eventos de Auditoría por Acción', description: 'Distribución de eventos de auditoría por tipo de acción' }, widget_events_by_user: { title: 'Eventos por Usuario', description: 'Distribución de actividad entre usuarios' }, - widget_recent_events: { title: 'Eventos de Auditoría Recientes', description: 'Últimos eventos de la plataforma (inicio de sesión, permisos, configuración, …)' }, + widget_recent_events: { title: 'Eventos de Auditoría Recientes', description: 'Últimos eventos de la plataforma (inicio de sesión, cierre de sesión, configuración, …)' }, }, }, }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 1d8632b131..29bc84ac29 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -148,11 +148,10 @@ export const jaJP: TranslationData = { widget_active_sessions: { title: 'アクティブセッション', description: '現在アクティブなユーザーセッション数' }, widget_packages_installed: { title: 'インストール済みパッケージ', description: 'プロジェクトでアクティブなパッケージインストール数' }, widget_login_events: { title: 'ログインイベント', description: '監査ログに記録された認証イベント' }, - widget_permission_changes: { title: '権限変更', description: '最近の権限とロールの変更' }, widget_config_changes: { title: '構成変更', description: 'システム構成の変更' }, widget_events_by_type: { title: 'アクション別監査イベント', description: 'アクションタイプ別の監査イベント分布' }, widget_events_by_user: { title: 'ユーザー別イベント', description: 'ユーザー別アクティビティ分布' }, - widget_recent_events: { title: '最近の監査イベント', description: '最新のプラットフォームイベント(ログイン、権限、構成など)' }, + widget_recent_events: { title: '最近の監査イベント', description: '最新のプラットフォームイベント(ログイン、ログアウト、構成など)' }, }, }, }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index a0260ab1be..70b0398459 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -158,11 +158,10 @@ export const zhCN: TranslationData = { widget_active_sessions: { title: '活跃会话', description: '当前活跃用户会话数量' }, widget_packages_installed: { title: '已安装包', description: '项目中已激活的安装包数' }, widget_login_events: { title: '登录事件', description: '审计日志中记录的认证事件' }, - widget_permission_changes: { title: '权限变更', description: '最近的权限和角色修改' }, widget_config_changes: { title: '配置变更', description: '系统配置修改' }, widget_events_by_type: { title: '按操作分布的审计事件', description: '审计事件按操作类型分布' }, widget_events_by_user: { title: '按用户分布的事件', description: '用户活动分布' }, - widget_recent_events: { title: '最近审计事件', description: '最新的平台事件(登录、权限、配置等)' }, + widget_recent_events: { title: '最近审计事件', description: '最新的平台事件(登录、登出、配置等)' }, }, }, },