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
56 changes: 56 additions & 0 deletions .changeset/sys-capability-deactivate-claim.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/plugin-security": patch
---

fix(plugin-security): withdraw the `sys_capability` Deactivate dialog's false promise that deactivation revokes access (#8535)

**A shipped confirmation dialog's promise is being withdrawn.** The
`deactivate_capability` action told the admin, verbatim:

> Deactivate this capability? Grants and resource requirements that reference it
> stop resolving until re-activated.

No code path has ever enforced that. `PermissionEvaluator.getSystemPermissions()`
unions `permissionSets[].systemPermissions` — plain strings — and a resource's
`requiredPermissions` is matched against that string set. Neither loads a
`sys_capability` row. The table's only two production readers are the seeders
(`bootstrap-system-capabilities.ts`, `bootstrap-declared-capabilities.ts`), which
**write** `active: true` on insert and never read it back.

**What `active` actually means now, stated plainly:** it is a catalogue /
visibility flag. It marks a row inactive for filtering and review in Setup, and it
has **no authorization effect whatsoever**. Deactivating a capability revokes
nothing — permission sets that grant it and resources that require it match it by
name and keep resolving exactly as before.

The direction of the old falsehood was the dangerous one. An admin withdrawing a
capability was told the withdrawal took effect, and it silently did not — the
escalation is what they believed they had prevented. This is ADR-0049
enforce-or-remove; per the maintainer ruling of 2026-08-13 the claim is
**withdrawn, not enforced**. Putting the capability registry on the authorization
hot path is an architectural change — caching, fail-closed semantics, org-authored
rows influencing platform capabilities — that must arrive as a designed feature
with its own card if capability lifecycle management ever earns real pull, not as
a side effect of wiring up one field.

Changes, all presentation and text — no behaviour changes, because there was no
behaviour to change:

- the confirmation dialog now states the non-effect outright rather than merely
omitting the promise: an admin who remembers the old wording has to be told it
was wrong, not left to infer it;
- the same correction is made in **all four shipped locales** (`en`, `es-ES`,
`ja-JP`, `zh-CN`). Editing the source object does **not** rewrite shipped
bundles — the extractor preserves existing leaf values, so a changed string
stays stale in every locale until corrected by hand;
- `active` gains a `description` it never had, declaring its real semantics
including the negative half. Its absence is how the dialog became the only place
the field's meaning was stated — and that statement was false;
- `active` is demoted from `highlightFields`, from the `danger` action variant,
and from the two scoped list views, and stays in the full-catalogue view where a
catalogue attribute belongs. A truthful dialog under a field still presented as
first-class tells the admin the flag matters after all.

Admins who deactivated a capability expecting access to stop should be aware that
access never stopped, and should withdraw the grant itself (the permission set's
`systemPermissions`) instead.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#8535] `sys_capability.active` claims nothing about authorization — on ANY
// shipped surface, in ANY locale.
//
// The defect this pins: `deactivate_capability`'s confirmation dialog told the
// admin "Grants and resource requirements that reference it stop resolving until
// re-activated." Nothing enforced that. `PermissionEvaluator.getSystemPermissions()`
// unions `permissionSets[].systemPermissions` — plain strings — and a resource's
// `requiredPermissions` is matched against that string set; neither loads a
// `sys_capability` row. The table's only two production readers are seeders, which
// WRITE `active: true` on insert and never read it back. So an admin was told a
// withdrawal took effect and it silently did not — the escalation is what they
// believed they had prevented.
//
// The maintainer ruled (2026-08-13) that ENFORCEMENT is not the answer: putting the
// registry on the authorization hot path is an architectural change that needs its
// own designed card. The claim is withdrawn instead.
//
// ── Why this file is a SWEEP and not a pin ──────────────────────────────────
// Asserting the new wording would be the weak test: it passes the moment the new
// sentence exists, on the one surface it names, and says nothing about the five
// other places the old sentence was ALSO shipped. This walks every string on every
// surface — the object definition and all four locale bundles — and applies two
// independent checks to each:
//
// NEGATIVE the withdrawn claim's own words, per locale, appear nowhere. Catches
// a surface that was missed, and a surface added later that copies the
// old text back in.
// POSITIVE every locale's dialog and field help actually STATE the non-effect.
// Catches the cheap non-fix — deleting the false sentence and leaving
// an admin who remembers it to infer the rest — which the negative
// check alone would pass.
//
// The negative check cannot catch an arbitrary paraphrase of the falsehood in a
// language it does not read; that is why the positive check carries equal weight.
//
// If capability lifecycle ever becomes genuinely enforceable (its own card, per the
// ruling), this file is what must change with it — the wording is only true while
// `active` is unread.

import { describe, it, expect } from 'vitest';
import { SysCapability } from './sys-capability.object.js';
import { enObjects } from '../translations/en.objects.generated.js';
import { esESObjects } from '../translations/es-ES.objects.generated.js';
import { jaJPObjects } from '../translations/ja-JP.objects.generated.js';
import { zhCNObjects } from '../translations/zh-CN.objects.generated.js';

/** Every `[path, value]` string reachable in a plain-data tree. */
function walkStrings(node: unknown, path = ''): Array<[string, string]> {
if (typeof node === 'string') return [[path, node]];
if (Array.isArray(node)) return node.flatMap((v, i) => walkStrings(v, `${path}[${i}]`));
if (node && typeof node === 'object') {
return Object.entries(node as Record<string, unknown>).flatMap(([k, v]) =>
walkStrings(v, path ? `${path}.${k}` : k),
);
}
return [];
}

/**
* The withdrawn claim, in the exact words each locale shipped it in (taken
* verbatim from the pre-fix bundles). A surface carrying any of these is
* re-asserting that deactivation withdraws access.
*/
const WITHDRAWN_CLAIM: Record<string, RegExp[]> = {
en: [/stop resolving/i, /until re-activated/i],
'es-ES': [/dejar[áa]n de resolverse/i, /hasta que se reactive/i],
'ja-JP': [/解決されなくなります/],
'zh-CN': [/无法解析/],
};

/** The honest statement each locale must actually make (not merely omit the lie). */
const NON_EFFECT_MARKER: Record<string, RegExp> = {
en: /authorization is not affected|no authorization effect/i,
'es-ES': /no se ve afectada|no tiene ning[úu]n efecto sobre la autorizaci[óo]n/i,
'ja-JP': /認可には(影響しません|一切影響しません)/,
'zh-CN': /授权(不受影响|没有任何影响)/,
};

const BUNDLES: Array<[string, Record<string, any>]> = [
['en', enObjects],
['es-ES', esESObjects],
['ja-JP', jaJPObjects],
['zh-CN', zhCNObjects],
];

describe('[#8535] sys_capability deactivation claims no authorization effect', () => {
// ── NEGATIVE sweep ────────────────────────────────────────────────────────
it('the object definition carries the withdrawn claim on no string at all', () => {
const offenders = walkStrings(SysCapability)
.filter(([, v]) => WITHDRAWN_CLAIM.en.some((re) => re.test(v)))
.map(([p, v]) => `${p}: ${v}`);
expect(
offenders,
'a string on SysCapability states that deactivation stops grants/requirements resolving. ' +
'Nothing enforces that (getSystemPermissions unions permission-set strings; no row is ever ' +
'read). Either withdraw the claim, or land enforcement as its own card first.',
).toEqual([]);
});

it.each(BUNDLES)('the %s bundle carries the withdrawn claim on no string at all', (locale, bundle) => {
const patterns = WITHDRAWN_CLAIM[locale];
expect(patterns, `no withdrawn-claim patterns registered for locale ${locale}`).toBeDefined();
const offenders = walkStrings(bundle.sys_capability, `${locale}.sys_capability`)
.filter(([, v]) => patterns.some((re) => re.test(v)))
.map(([p, v]) => `${p}: ${v}`);
expect(
offenders,
`the ${locale} bundle still ships the withdrawn claim. Editing the source object does NOT ` +
'rewrite shipped bundles — `check-i18n-bundles.mjs --write` reports "regenerated" and leaves ' +
'changed leaf values untouched, so this locale must be corrected by hand.',
).toEqual([]);
});

// ── POSITIVE sweep ────────────────────────────────────────────────────────
it('the source dialog states the non-effect outright', () => {
const action: any = (SysCapability.actions ?? []).find((a: any) => a.name === 'deactivate_capability');
expect(action, 'deactivate_capability action exists').toBeDefined();
expect(
action.confirmText,
'the dialog must SAY authorization is unaffected, not merely stop lying about it — an admin ' +
'who remembers the old wording has to be told it was wrong.',
).toMatch(NON_EFFECT_MARKER.en);
});

it.each(BUNDLES)('the %s dialog states the non-effect outright', (locale, bundle) => {
const confirmText = bundle.sys_capability?._actions?.deactivate_capability?.confirmText;
expect(confirmText, `${locale} bundle has a deactivate_capability confirmText`).toBeTruthy();
expect(confirmText).toMatch(NON_EFFECT_MARKER[locale]);
});

it('the active field documents its own inertness at the source', () => {
const active: any = (SysCapability.fields as any).active;
expect(active, 'active field exists').toBeDefined();
expect(
active.description,
'the field carried NO description at all, which is how the dialog became the only place its ' +
'meaning was stated — and that statement was false.',
).toMatch(NON_EFFECT_MARKER.en);
});

it.each(BUNDLES)('the %s bundle translates the active field help', (locale, bundle) => {
const help = bundle.sys_capability?.fields?.active?.help;
expect(help, `${locale} bundle has help for the active field`).toBeTruthy();
expect(
help,
`${locale} still carries the English fill for active.help — 'os i18n extract' seeds a NEW key ` +
'with the default-locale string, and leaving it is how a locale ships an untranslated surface.',
).toMatch(NON_EFFECT_MARKER[locale]);
});

// ── Demotion from prominence (the other half of the ruling) ────────────────
it('active is not a highlight field', () => {
expect(
SysCapability.highlightFields ?? [],
'record-header prominence beside scope/managed_by is itself a claim that the flag belongs to ' +
'the authorization posture — a truthful dialog under a first-class field still says it matters.',
).not.toContain('active');
});

it.each(['platform', 'org'])('the %s list view does not surface active as a column', (view) => {
const columns: string[] = ((SysCapability.listViews as any)[view]?.columns ?? []) as string[];
expect(columns.length, `${view} view has columns`).toBeGreaterThan(0);
expect(columns).not.toContain('active');
});

it('the full-catalogue view DOES still surface active', () => {
// Deliberately asserted: hiding a flag the product still lets an admin set is
// the opposite error, not a stronger fix. A catalogue attribute stays visible
// in the catalogue view.
expect((SysCapability.listViews as any).all_capabilities?.columns ?? []).toContain('active');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,13 @@ export const SysCapability = ObjectSchema.create({
displayNameField: 'label',
nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: '{label}',
highlightFields: ['label', 'name', 'scope', 'managed_by', 'active'],
// [#8535] `active` is deliberately NOT highlighted. It is a catalogue flag with
// no authorization effect (see the field's own comment), and record-header
// prominence next to `scope`/`managed_by` is itself a claim that it belongs to
// the authorization posture. Demoting it is half the fix; rewording the dialog
// is the other half — a truthful dialog under a field still presented as
// first-class tells the admin the flag matters after all.
highlightFields: ['label', 'name', 'scope', 'managed_by'],

actions: [
{
Expand All@@ -62,26 +68,59 @@ export const SysCapability = ObjectSchema.create({
name: 'deactivate_capability',
label: 'Deactivate',
icon: 'circle-off',
variant: 'danger',
// [#8535] Was `danger`. A danger variant is a claim in itself — it tells the
// admin the click has consequences proportionate to a security control. This
// one writes a catalogue column and nothing else.
variant: 'secondary',
mode: 'custom',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'PATCH',
target: '/api/v1/data/sys_capability/{id}',
bodyExtra: { active: false },
confirmText: 'Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.',
// [#8535] This used to read: "Deactivate this capability? Grants and resource
// requirements that reference it stop resolving until re-activated." No code
// path has ever enforced that. `PermissionEvaluator.getSystemPermissions()`
// unions `permissionSets[].systemPermissions` — plain strings — and
// `requiredPermissions` is compared against that string set; neither loads a
// `sys_capability` row. The two production readers of the table are both
// seeders (`bootstrap-system-capabilities.ts`,
// `bootstrap-declared-capabilities.ts`), which WRITE `active: true` on insert
// and never read it back.
//
// The direction of that falsehood was the dangerous one: an admin withdrawing
// a capability was told in a confirmation dialog that the withdrawal took
// effect, and it silently did not — the escalation is what they believed they
// had prevented. ADR-0049 enforce-or-remove; the maintainer ruled (2026-08-13)
// that enforcement is NOT the answer here — putting the registry on the
// authorization hot path is an architectural change (caching, fail-closed
// semantics, org-authored rows influencing platform capabilities) that needs
// its own designed card if capability lifecycle management ever earns real
// pull. So the claim is withdrawn instead, and the dialog now states the
// non-effect explicitly rather than merely omitting the promise: an admin who
// remembers the old wording has to be told it was wrong, not left to infer it.
confirmText:
'Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.',
successMessage: 'Capability deactivated',
refreshAfter: true,
},
],

// [#8535] `active` was a column in ALL THREE views. It is now shown only in
// `all_capabilities` — the full-catalogue view, where a catalogue attribute
// genuinely belongs and stays observable and filterable for the admin who sets
// it. The two SCOPED views (`platform`, `org`) are the ones an admin works in
// to reason about who can do what, and a column sitting next to `managed_by`
// there reads as part of the authorization posture. Dropping it from all three
// was rejected as the opposite error: a flag the product lets you set but never
// lets you see is its own kind of dishonest surface.
listViews: {
platform: {
type: 'grid',
name: 'platform',
label: 'Platform',
data: { provider: 'object', object: 'sys_capability' },
columns: ['label', 'name', 'managed_by', 'active'],
columns: ['label', 'name', 'managed_by'],
filter: [{ field: 'scope', operator: 'equals', value: 'platform' }],
sort: [{ field: 'name', order: 'asc' }],
pagination: { pageSize: 50 },
Expand All@@ -91,7 +130,7 @@ export const SysCapability = ObjectSchema.create({
name: 'org',
label: 'Organization',
data: { provider: 'object', object: 'sys_capability' },
columns: ['label', 'name', 'managed_by', 'active'],
columns: ['label', 'name', 'managed_by'],
filter: [{ field: 'scope', operator: 'equals', value: 'org' }],
sort: [{ field: 'name', order: 'asc' }],
pagination: { pageSize: 50 },
Expand DownExpand Up@@ -173,9 +212,19 @@ export const SysCapability = ObjectSchema.create({
}),

// ── Status ───────────────────────────────────────────────────
// [#8535] Catalogue flag, NOT an enforcement switch. It carried no
// `description` at all, which is how the `deactivate_capability` dialog
// became the only place its meaning was stated — and that statement was
// false. The semantics are declared here now, negative half included, so the
// field documents its own inertness at the point an author or an admin meets
// it. If capability lifecycle ever becomes enforceable it arrives as a
// designed feature with its own card (maintainer ruling, 2026-08-13), and
// this comment plus the description are what must change with it.
active: Field.boolean({
label: 'Active',
defaultValue: true,
description:
'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.',
group: 'Status',
}),

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -139,7 +139,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
help: "Package that ships this capability (absent = platform-curated or admin-created)."
},
active: {
label: "Active"
label: "Active",
help: "Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing."
},
id: {
label: "Capability ID"
Expand DownExpand Up@@ -169,7 +170,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
deactivate_capability: {
label: "Deactivate",
confirmText: "Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.",
confirmText: "Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.",
successMessage: "Capability deactivated"
}
}
Expand Down
Loading
Loading