Skip to content

Commit 1690bce

Browse files
committed
Removed the permission to read member custom field definitions
ref https://linear.app/ghost/issue/BER-3922/rename-the-code-behind-the-metafields-api-to-match-the-domain-language Reading a field definition is no longer gated by a permission. A definition says only that the site collects a shoe size, and every signed-in member is already shown the whole list through Portal, so holding it back from staff protected nothing that was not already on offer elsewhere. It was also inverted. An integration holding member browse received members' field values on the member payload, which never consults this permission, while a request for the definitions describing those same values was refused. It could read the data but not the schema, which is the wrong way round. The routes had already drawn the line correctly and deliberately: reads are unflagged so Admin can ask any site and get an empty list, and everything that changes something is flagged. Only the permission layer had not caught up. What remains on the resource is defining fields, which stays with the publisher. The smaller change alongside it is one of order. Which namespace is being written to now settles before who is asking, so a namespace nobody can define in refuses on that ground whatever role the caller holds, rather than answering with a missing permission that no one could have been granted. That is also where namespace ownership belongs once an app owns a namespace of its own, since the caller will then have to be that app rather than hold a role. Claude-Session: https://claude.ai/code/session_01XFCbqgYHYhd9rZ5WXtqZyT
1 parent 882b072 commit 1690bce

10 files changed

Lines changed: 107 additions & 62 deletions

File tree

ghost/core/core/server/api/endpoints/member-metafields.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { actingContext, definitions } from '../../services/members-metafields';
2+
import { assertDefinable } from '../../services/members-metafields/namespaces';
23

34
const permissionsService = require('../../services/permissions');
45

@@ -10,13 +11,30 @@ interface Frame {
1011
options: { namespace: string; key: string; context: unknown; [key: string]: unknown };
1112
}
1213

13-
// With `permissions: true` the framework checks against the Bookshelf model named after
14-
// the resource. These fields have no Bookshelf model, so each handler asks the permissions
15-
// service directly.
14+
// Reading a definition needs no permission. A definition says only that the site collects
15+
// a shoe size, and every signed-in member is already shown the whole list, so there is
16+
// nothing here to keep from staff. Defining one is the publisher's own, and with
17+
// `permissions: true` the framework would check against the Bookshelf model named after
18+
// the resource — these fields have no Bookshelf model, so each writing handler asks the
19+
// permissions service directly.
1620
function canThis(frame: Frame) {
1721
return permissionsService.canThis(frame.options.context);
1822
}
1923

24+
/**
25+
* Settle the namespace before the caller.
26+
*
27+
* Which namespace is being written to decides whose authority applies, so it is resolved
28+
* first; only once the publisher turns out to own it does a staff role become the
29+
* question. The other order answers a request to define a field somewhere nobody owns
30+
* with "you lack a permission", which sends the caller after a permission that would not
31+
* have helped.
32+
*/
33+
async function canDefine(frame: Frame, action: (frame: Frame) => Promise<unknown>) {
34+
assertDefinable(frame.options.namespace);
35+
return action(frame);
36+
}
37+
2038
const noCacheInvalidation = { cacheInvalidate: false };
2139

2240
const controller = {
@@ -26,9 +44,7 @@ const controller = {
2644
headers: noCacheInvalidation,
2745
options: ['namespace', 'filter'],
2846
validation: { options: { namespace: { required: true } } },
29-
permissions(frame: Frame) {
30-
return canThis(frame).browse.member_custom_field();
31-
},
47+
permissions: false,
3248
query(frame: Frame) {
3349
return definitions!.browse({
3450
namespace: frame.options.namespace,
@@ -41,9 +57,7 @@ const controller = {
4157
headers: noCacheInvalidation,
4258
options: ['namespace', 'key'],
4359
validation: { options: { namespace: { required: true }, key: { required: true } } },
44-
permissions(frame: Frame) {
45-
return canThis(frame).read.member_custom_field(frame.options.key);
46-
},
60+
permissions: false,
4761
query(frame: Frame) {
4862
return definitions!.read(frame.options.namespace, frame.options.key);
4963
},
@@ -55,7 +69,7 @@ const controller = {
5569
options: ['namespace'],
5670
validation: { options: { namespace: { required: true } } },
5771
permissions(frame: Frame) {
58-
return canThis(frame).add.member_custom_field();
72+
return canDefine(frame, (f) => canThis(f).add.member_custom_field());
5973
},
6074
query(frame: Frame) {
6175
return definitions!.add(
@@ -71,7 +85,7 @@ const controller = {
7185
options: ['namespace'],
7286
validation: { options: { namespace: { required: true } } },
7387
permissions(frame: Frame) {
74-
return canThis(frame).edit.member_custom_field();
88+
return canDefine(frame, (f) => canThis(f).edit.member_custom_field());
7589
},
7690
query(frame: Frame) {
7791
return definitions!.reorder(
@@ -87,7 +101,7 @@ const controller = {
87101
options: ['namespace', 'key'],
88102
validation: { options: { namespace: { required: true }, key: { required: true } } },
89103
permissions(frame: Frame) {
90-
return canThis(frame).edit.member_custom_field(frame.options.key);
104+
return canDefine(frame, (f) => canThis(f).edit.member_custom_field(f.options.key));
91105
},
92106
query(frame: Frame) {
93107
return definitions!.edit(
@@ -105,7 +119,7 @@ const controller = {
105119
options: ['namespace', 'key'],
106120
validation: { options: { namespace: { required: true }, key: { required: true } } },
107121
permissions(frame: Frame) {
108-
return canThis(frame).destroy.member_custom_field(frame.options.key);
122+
return canDefine(frame, (f) => canThis(f).destroy.member_custom_field(f.options.key));
109123
},
110124
async query(frame: Frame) {
111125
await definitions!.destroy(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const { combineTransactionalMigrations, createRemovePermissionMigration } = require('../../utils');
2+
3+
const RESOURCE = 'member_custom_field';
4+
5+
// Reading a field definition is no longer a permission. A definition says a site collects
6+
// a shoe size; every signed-in member already sees the whole list through Portal, so
7+
// gating staff behind a role protected nothing and inverted the two halves: an integration
8+
// holding `member: browse` received members' field values on the member payload, which
9+
// never consults this permission, while a request for the definitions describing those
10+
// values was refused.
11+
//
12+
// What remains on this resource is defining fields, which stays with the publisher.
13+
module.exports = combineTransactionalMigrations(
14+
createRemovePermissionMigration(
15+
{
16+
name: 'Browse member custom fields',
17+
action: 'browse',
18+
object: RESOURCE,
19+
},
20+
['Administrator', 'Admin Integration', 'Super Editor'],
21+
),
22+
createRemovePermissionMigration(
23+
{
24+
name: 'Read member custom fields',
25+
action: 'read',
26+
object: RESOURCE,
27+
},
28+
['Administrator', 'Admin Integration', 'Super Editor'],
29+
),
30+
);

ghost/core/core/server/data/schema/fixtures/fixtures.json

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -515,16 +515,6 @@
515515
"action_type": "destroy",
516516
"object_type": "label"
517517
},
518-
{
519-
"name": "Browse member custom fields",
520-
"action_type": "browse",
521-
"object_type": "member_custom_field"
522-
},
523-
{
524-
"name": "Read member custom fields",
525-
"action_type": "read",
526-
"object_type": "member_custom_field"
527-
},
528518
{
529519
"name": "Edit member custom fields",
530520
"action_type": "edit",
@@ -1069,7 +1059,6 @@
10691059
"collection": "all",
10701060
"recommendation": ["browse", "read"],
10711061
"member": ["browse", "read", "add", "edit", "destroy"],
1072-
"member_custom_field": ["browse", "read"],
10731062
"member_signin_url": "read",
10741063
"offer": ["browse", "read"],
10751064
"comment": "all",

ghost/core/core/server/services/members-metafields/definitions-service.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Metafield } from './models';
66
import { FieldTypeSchema, type FieldType } from '@tryghost/metafield-types';
77
import { CUSTOM_NAMESPACE } from '@tryghost/metafield-types/identity';
88
import { metafieldCodec } from './codec';
9+
import { assertDefinable } from './namespaces';
910
import { FIELD_STATUS, FieldStatusSchema } from './schema';
1011
import { ADMIN, readableFields, type Audience } from './access';
1112
import { activeFields, fieldByKey, inFieldOrder, type DefinitionQuery } from './queries';
@@ -133,15 +134,6 @@ export class MetafieldDefinitionsService {
133134
return namespace === CUSTOM_NAMESPACE;
134135
}
135136

136-
private assertDefinable(namespace: string): void {
137-
if (!this.isStored(namespace)) {
138-
throw new errors.ValidationError({
139-
message: `Fields cannot be defined in the "${namespace}" namespace.`,
140-
property: 'namespace',
141-
});
142-
}
143-
}
144-
145137
async browse(
146138
options: { namespace?: string; filter?: string } = {},
147139
audience: Audience = ADMIN,
@@ -192,7 +184,7 @@ export class MetafieldDefinitionsService {
192184
* key get distinct ones, exactly as if they had arrived as separate requests.
193185
*/
194186
async add(context: RequestContext, namespace: string, input: unknown): Promise<Metafield[]> {
195-
this.assertDefinable(namespace);
187+
assertDefinable(namespace);
196188
const requestedCount = Array.isArray(input) ? input.length : 0;
197189

198190
const parsed = AddFieldsInput.safeParse(input);
@@ -399,7 +391,7 @@ export class MetafieldDefinitionsService {
399391
* Returns every definition, archived included, matching what the request named.
400392
*/
401393
async reorder(context: RequestContext, namespace: string, input: unknown): Promise<Metafield[]> {
402-
this.assertDefinable(namespace);
394+
assertDefinable(namespace);
403395
const parsed = ReorderInput.safeParse(input);
404396
if (!parsed.success) {
405397
const issue = parsed.error.issues[0];
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import errors from '@tryghost/errors';
2+
import { CUSTOM_NAMESPACE } from '@tryghost/metafield-types/identity';
3+
4+
/**
5+
* Who may define fields in a namespace.
6+
*
7+
* This is a property of the namespace, not of whoever is asking, so it is settled before
8+
* any question about the caller. Asking the caller first gets the order wrong in a way
9+
* that shows: a request to define a field in a namespace nobody owns would be refused for
10+
* want of a permission, telling the caller to go and get one, when no permission would
11+
* have helped.
12+
*
13+
* The publisher owns `custom` and nothing owns anything else yet. An app owning its own
14+
* namespace resolves here too, and will not resolve to a permission at all: the caller
15+
* has to *be* that app rather than hold a role, which is why the staff permission stays
16+
* named after the publisher's fields rather than after metafields at large.
17+
*/
18+
export function definableByPublisher(namespace: string): boolean {
19+
return namespace === CUSTOM_NAMESPACE;
20+
}
21+
22+
export function assertDefinable(namespace: string): void {
23+
if (!definableByPublisher(namespace)) {
24+
throw new errors.ValidationError({
25+
message: `Fields cannot be defined in the "${namespace}" namespace.`,
26+
property: 'namespace',
27+
});
28+
}
29+
}

ghost/core/test/e2e-api/admin/member-custom-fields.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,7 +2230,7 @@ describe('Member Custom Fields Admin API', function () {
22302230

22312231
describe('Authorization', function () {
22322232
// The full role matrix is pinned in migration.test.js; here we only prove
2233-
// the endpoint enforces the permission — a role without it is rejected.
2233+
// the endpoint enforces the permission — a role without it may look but not touch.
22342234
beforeAll(async function () {
22352235
await agent.loginAsEditor();
22362236
});
@@ -2239,8 +2239,9 @@ describe('Member Custom Fields Admin API', function () {
22392239
await agent.loginAsOwner();
22402240
});
22412241

2242-
it('forbids a role without permission from browsing', async function () {
2243-
await agent.get('members/metafields/custom/').expectStatus(403);
2242+
it('lets a role without permission read what the site collects', async function () {
2243+
await agent.get('members/metafields/custom/').expectStatus(200);
2244+
await agent.get('members/metafields/custom/company/').expectStatus(404);
22442245
});
22452246

22462247
it('forbids a role without permission from creating', async function () {
@@ -2256,6 +2257,17 @@ describe('Member Custom Fields Admin API', function () {
22562257
.body({ members_metafields: [{ key: 'topic' }] })
22572258
.expectStatus(403);
22582259
});
2260+
2261+
it('answers for the namespace before it answers for the caller', async function () {
2262+
// Nobody can define a field here, so the namespace is the reason and the role is
2263+
// beside the point. Answering 403 would send this caller after a permission that
2264+
// would not have helped them.
2265+
const { body } = await agent
2266+
.post('members/metafields/shopify/')
2267+
.body({ members_metafields: [{ name: 'Topic', type: 'short_text' }] })
2268+
.expectStatus(422);
2269+
assert.match(body.errors[0].context, /shopify/);
2270+
});
22592271
});
22602272

22612273
// The flag governs whether a publisher can set custom fields up, not whether the rest of

ghost/core/test/integration/migrations/migration.test.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('Migrations', function () {
8787
// Custom assertion to wrap all permissions
8888
function assertCompletePermissions(permissions) {
8989
// If you have to change this number, please add the relevant `assertHavePermission` checks below
90-
assert.equal(permissions.length, 143);
90+
assert.equal(permissions.length, 141);
9191

9292
assertHavePermission(permissions, 'Export database', [
9393
'Administrator',
@@ -477,16 +477,6 @@ describe('Migrations', function () {
477477
'Super Editor',
478478
]);
479479

480-
assertHavePermission(permissions, 'Browse member custom fields', [
481-
'Administrator',
482-
'Admin Integration',
483-
'Super Editor',
484-
]);
485-
assertHavePermission(permissions, 'Read member custom fields', [
486-
'Administrator',
487-
'Admin Integration',
488-
'Super Editor',
489-
]);
490480
assertHavePermission(permissions, 'Add member custom fields', [
491481
'Administrator',
492482
'Admin Integration',

ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ describe('Migration Fixture Utils', function () {
398398
const rolesAllStub = sinon.stub(models.Role, 'findAll').returns(Promise.resolve(dataMethodStub));
399399

400400
const result = await fixtureManager.addFixturesForRelation(fixtures.relations[0]);
401-
const FIXTURE_COUNT = 150;
401+
const FIXTURE_COUNT = 149;
402402
assertExists(result);
403403
assert(_.isPlainObject(result));
404404
assert.equal(result.expected, FIXTURE_COUNT);

ghost/core/test/unit/server/data/schema/integrity.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya
3838
describe('DB version integrity', function () {
3939
// Only these variables should need updating
4040
const currentSchemaHash = '29d413d3d965639382e8506d5db877d2';
41-
const currentFixturesHash = '1727a789194847da33d68dd95301b416';
41+
const currentFixturesHash = '5718e0d4eb037f159c312369e949829a';
4242
const currentSettingsHash = '6ea42a00cca61a1ba87f66eb6e25a78a';
4343
const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1';
4444

ghost/core/test/utils/fixtures/fixtures.json

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -546,16 +546,6 @@
546546
"action_type": "destroy",
547547
"object_type": "label"
548548
},
549-
{
550-
"name": "Browse member custom fields",
551-
"action_type": "browse",
552-
"object_type": "member_custom_field"
553-
},
554-
{
555-
"name": "Read member custom fields",
556-
"action_type": "read",
557-
"object_type": "member_custom_field"
558-
},
559549
{
560550
"name": "Edit member custom fields",
561551
"action_type": "edit",
@@ -1261,7 +1251,6 @@
12611251
"recommendation": ["browse", "read"],
12621252
"member": ["browse", "read", "add", "edit", "destroy"],
12631253
"member_signin_url": "read",
1264-
"member_custom_field": ["browse", "read"],
12651254
"offer": ["browse", "read"],
12661255
"comment": "all",
12671256
"gift_link": "manage"

0 commit comments

Comments
 (0)