Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/e2e/feature-validation.tsv

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,8 @@ Plugins read and write CMS content (pages, posts, custom tables) through `api.cm

The manifest's `contentAccess[]` lists every table whose entries the plugin can touch, with per-table modes. The host fails closed without both the permission and the allowlist entry for entry reads/writes/publishes/deletes. `content.tables.create(...)` is different: it requires `cms.content.tables.manage`, creates a new user-managed table, and does not require a pre-existing `contentAccess[]` row for that table.

The install/upgrade consent screen renders `contentAccess[]` in its own "Content tables" section, mirroring the "External hosts" section: every entry is listed with its modes (an `@own-created` entry renders as "Tables this plugin creates"), and on upgrade entries are diffed against the previously-installed manifest — a new table, or a new mode on an already-approved table, is badged as new; entries the update no longer requests show as dropped.

```jsonc
{
"permissions": ["cms.content.read", "cms.content.write"],
Expand Down Expand Up @@ -883,7 +885,7 @@ The DNS SSRF guard in `performGatedFetch` remains the load-bearing defense; the

Permissions are requested in `plugin.json` and approved by the site owner at install time. Granted permissions are stored on the plugin row. Every SDK call checks the **granted** permission set, not just the request.

The install endpoints enforce **grants = declared**, in both directions: every declared permission must be granted (install is all-or-nothing — there is no optional-permissions concept), and every granted permission must be declared (`assertPluginPermissionGrants` in `server/handlers/cms/plugins/shared.ts` rejects a tampered client that grants capabilities the manifest never disclosed). The install review dialog is shown for **every** install and upgrade — a zero-permission plugin renders "No permissions requested" rather than installing silently.
The install endpoints enforce **grants = declared**, in both directions: every declared permission must be granted (install is all-or-nothing — there is no optional-permissions concept), and every granted permission must be declared (`assertPluginPermissionGrants` in `server/handlers/cms/plugins/shared.ts` rejects a tampered client that grants capabilities the manifest never disclosed). The install review dialog is shown for **every** install and upgrade — a zero-permission plugin renders "No permissions requested" rather than installing silently. Alongside the requested permissions, the dialog renders the manifest's `contentAccess[]` (per-table content allowlist, with modes) and `networkAllowedHosts` (outbound-host allowlist), and on upgrade diffs all three against the prior install so new access is impossible to miss.

**One authority, three checkpoints.** The declared `permissions` array (what the
plugin *asked for*) is used only by the install/consent UI. Enforcement always
Expand Down
193 changes: 192 additions & 1 deletion src/__tests__/plugins/permissionReviewSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
*
* The critical safety invariant is: when a plugin upgrade requests new
* permissions, the UI must surface them prominently so the site owner
* can spot a permission expansion before clicking "Update".
* can spot a permission expansion before clicking "Update". The same
* invariant covers the manifest's `contentAccess[]` allowlist — a new
* table, or a new mode on an already-approved table, must show as new.
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { cleanup, render, screen } from '@testing-library/react'
import {
OWN_CREATED_CONTENT_TABLE,
PermissionReviewSection,
computeContentAccessDiff,
computePermissionDiff,
} from '@plugins/components/PermissionReviewSection'
import type { PluginManifest, PluginPermission } from '@core/plugin-sdk'
Expand Down Expand Up @@ -69,6 +73,71 @@ describe('computePermissionDiff', () => {
})
})

describe('computeContentAccessDiff', () => {
it('marks every entry new on a fresh install and sorts modes canonically', () => {
const rows = computeContentAccessDiff(
[{ table: 'posts', modes: ['write', 'read'] }],
undefined,
false,
)
expect(rows).toEqual([
{ table: 'posts', modes: ['read', 'write'], addedModes: [], status: 'new' },
])
})

it('diffs new, existing, and dropped tables on upgrade, new first', () => {
const rows = computeContentAccessDiff(
[
{ table: 'pages', modes: ['read'] },
{ table: 'reviews', modes: ['read', 'write'] },
],
[
{ table: 'pages', modes: ['read'] },
{ table: 'posts', modes: ['read'] },
],
true,
)
expect(rows.map((r) => [r.table, r.status])).toEqual([
['reviews', 'new'],
['pages', 'existing'],
['posts', 'dropped'],
])
})

it('promotes an already-approved table to new when the update adds modes', () => {
const rows = computeContentAccessDiff(
[{ table: 'posts', modes: ['read', 'write'] }],
[{ table: 'posts', modes: ['read'] }],
true,
)
expect(rows).toEqual([
{ table: 'posts', modes: ['read', 'write'], addedModes: ['write'], status: 'new' },
])
})

it('keeps a table with reduced modes as existing, showing the requested modes', () => {
const rows = computeContentAccessDiff(
[{ table: 'posts', modes: ['read'] }],
[{ table: 'posts', modes: ['read', 'write'] }],
true,
)
expect(rows).toEqual([
{ table: 'posts', modes: ['read'], addedModes: [], status: 'existing' },
])
})

it('renders dropped rows with the previously-declared modes', () => {
const rows = computeContentAccessDiff(
[],
[{ table: 'posts', modes: ['delete', 'read'] }],
true,
)
expect(rows).toEqual([
{ table: 'posts', modes: ['read', 'delete'], addedModes: [], status: 'dropped' },
])
})
})

describe('PermissionReviewSection — fresh install', () => {
it('shows the review heading + lists every permission with no badges', () => {
render(
Expand Down Expand Up @@ -269,3 +338,125 @@ describe('PermissionReviewSection — upgrade with new permissions', () => {
expect(droppedRow?.getAttribute('data-permission')).toBe('cms.storage')
})
})

describe('PermissionReviewSection — content tables', () => {
it('lists each contentAccess entry with its modes on a fresh install', () => {
render(
<PermissionReviewSection
pending={{
manifest: {
...baseManifest,
permissions: [
'cms.content.read',
'cms.content.write',
] satisfies PluginPermission[],
contentAccess: [
{ table: 'posts', modes: ['read', 'write'] },
{ table: 'pages', modes: ['read'] },
],
},
}}
uploading={false}
onCancel={() => {}}
onConfirm={() => {}}
/>,
)
const section = screen.getByTestId('permission-review-content-tables')
expect(section.textContent).toContain('Content tables')
const rows = Array.from(
section.querySelectorAll<HTMLElement>('[data-content-table]'),
)
expect(rows.map((row) => row.dataset.contentTable)).toEqual(['pages', 'posts'])
expect(rows[1].textContent).toContain('posts')
expect(rows[1].textContent).toContain('Read, write')
// Fresh install shows no diff badges.
expect(screen.queryByText('Already approved')).toBeNull()
})

it('omits the section when the manifest declares no contentAccess', () => {
render(
<PermissionReviewSection
pending={{
manifest: { ...baseManifest, permissions: ['cms.routes'] },
}}
uploading={false}
onCancel={() => {}}
onConfirm={() => {}}
/>,
)
expect(screen.queryByTestId('permission-review-content-tables')).toBeNull()
})

it('renders the @own-created marker human-readably', () => {
render(
<PermissionReviewSection
pending={{
manifest: {
...baseManifest,
permissions: [
'cms.content.read',
'cms.content.write',
] satisfies PluginPermission[],
contentAccess: [
{ table: OWN_CREATED_CONTENT_TABLE, modes: ['read', 'write'] },
],
},
}}
uploading={false}
onCancel={() => {}}
onConfirm={() => {}}
/>,
)
const section = screen.getByTestId('permission-review-content-tables')
expect(section.textContent).toContain('Tables this plugin creates')
expect(section.textContent).not.toContain(OWN_CREATED_CONTENT_TABLE)
})

it('badges new tables, added modes, and dropped tables on upgrade', () => {
render(
<PermissionReviewSection
pending={{
manifest: {
...baseManifest,
permissions: [
'cms.content.read',
'cms.content.write',
] satisfies PluginPermission[],
contentAccess: [
// `write` is newly requested on the already-approved table.
{ table: 'posts', modes: ['read', 'write'] },
// Brand-new table.
{ table: 'reviews', modes: ['read'] },
],
},
upgradeFromVersion: '1.0.0',
previouslyGrantedPermissions: [
'cms.content.read',
'cms.content.write',
] satisfies PluginPermission[],
previousContentAccess: [
{ table: 'posts', modes: ['read'] },
{ table: 'legacy', modes: ['read'] },
],
}}
uploading={false}
onCancel={() => {}}
onConfirm={() => {}}
/>,
)
const section = screen.getByTestId('permission-review-content-tables')
const rows = Array.from(
section.querySelectorAll<HTMLElement>('[data-content-table]'),
)
expect(rows.map((row) => [row.dataset.contentTable, row.dataset.status])).toEqual([
['posts', 'new'],
['reviews', 'new'],
['legacy', 'dropped'],
])
// The already-approved table that gained a mode calls the mode out.
expect(rows[0].textContent).toContain('newly requested: write')
// The dropped table shows its previously-declared modes, struck through.
expect(rows[2].textContent).toContain('No longer requested')
expect(rows[2].textContent).toContain('Read')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@
font-size: var(--text-m);
}

.row[data-status="dropped"] .label strong {
.row[data-status="dropped"] .label strong,
.row[data-status="dropped"] .label code {
text-decoration: line-through;
}

Expand Down Expand Up @@ -153,26 +154,27 @@
}

/*
* External-host allowlist section — surfaces `networkAllowedHosts` from
* the manifest separately from CMS permissions. The two are
* Manifest-allowlist sections — surface `contentAccess` (which CMS tables
* the plugin can touch, per mode) and `networkAllowedHosts` (which remote
* origins it can talk to) separately from CMS permissions. The three are
* complementary: permissions answer "what CMS surface can the plugin
* touch?", hosts answer "what remote origins can it talk to?".
* touch?", the allowlists answer "which concrete tables and origins?".
*/
.networkSection {
.allowlistSection {
display: flex;
flex-direction: column;
gap: var(--space-s);
padding-top: var(--space-s);
border-top: 1px solid var(--border);
}

.networkHeader {
.allowlistHeader {
display: flex;
flex-direction: column;
gap: var(--space-4xs);
}

.networkHeader strong {
.allowlistHeader strong {
color: var(--text-bright);
font-size: var(--text-m);
font-weight: 600;
Expand Down
Loading