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
61 changes: 61 additions & 0 deletions .changeset/system-overview-permission-change-tile-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
---

fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675)

<!-- adr-0087: not-required (no-migration-prescription) A widget is removed from
a platform-shipped dashboard, and four hand-authored locale bundles drop the
matching `dashboards.system_overview.widgets.widget_permission_changes` subtree.
No authorable KEY changes: `DashboardWidgetSchema` is untouched, nothing is
renamed or tombstoned, and the retirement of the `sys_audit_log.action` VALUE
this tile filtered was registered by #8147 as a SEMANTIC entry
(`17.audit-log-action-enum-retired`). This change is the UI half of that already
registered retirement, so it prescribes no migration of its own. -->

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<string, unknown> | 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
);
expect(
translated.filter((id) => !declared.has(id)),
`dashboards.${dashboard.name}.widgets keys with no declaring widget`,
).toEqual([]);
});
}
});
6 changes: 1 addition & 5 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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, …)',
},
},
},
Expand Down
3 changes: 1 addition & 2 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, …)' },
},
},
},
Expand Down
3 changes: 1 addition & 2 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '最新のプラットフォームイベント(ログイン、ログアウト、構成など)' },
},
},
},
Expand Down
3 changes: 1 addition & 2 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '最新的平台事件(登录、登出、配置等)' },
},
},
},
Expand Down
Loading