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
21 changes: 19 additions & 2 deletions apps/admin-x-framework/src/api/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {JSONObject} from './config';

export type Action = {
id: string;
resource_id: string;
resource_id: string|null;
resource_type: string;
actor_id: string;
actor_type: string;
Expand Down Expand Up @@ -46,6 +46,17 @@ export interface ActionsList {

const dataType = 'ActionsResponseType';

export const actionsAreGroupable = (action: Action, nextAction: Action) => {
const getEditedActionName = (candidate: Action) => (
candidate.event === 'edited' && typeof candidate.context?.action_name === 'string'
) ? candidate.context.action_name : '';

return action.resource_id === nextAction.resource_id &&
action.resource_type === nextAction.resource_type &&
action.event === nextAction.event &&
getEditedActionName(action) === getEditedActionName(nextAction);
};

export const useBrowseActions = createInfiniteQuery<ActionsList>({
dataType,
path: '/actions/',
Expand All @@ -69,7 +80,7 @@ export const useBrowseActions = createInfiniteQuery<ActionsList>({
// depending on the similarity, add additional properties to be used on the frontend for grouping
// skip - used for hiding the event on the frontend
// count - the number of similar events which is added to the last item
if (nextAction && action.resource_id === nextAction.resource_id && action.event === nextAction.event) {
if (nextAction && actionsAreGroupable(action, nextAction)) {
action.skip = true;
count += 1;
} else if (count > 1) {
Expand Down Expand Up @@ -186,6 +197,8 @@ export const getActionTitle = (action: Action) => {
resourceType = 'tier';
} else if (resourceType === 'gift_link') {
resourceType = 'gift link';
} else if (resourceType === 'security_action') {
resourceType = 'security action';
}

// Because a `page` and `post` both use the same model, we store the
Expand All @@ -204,6 +217,10 @@ export const getActionTitle = (action: Action) => {
}
}

if (actionName === 'reset_authentication') {
actionName = 'reset authentication';
}

if (action.context?.count && (action.context?.count as number) > 1) {
return `${action.context?.count} ${resourceType}s ${actionName}`;
}
Expand Down
48 changes: 48 additions & 0 deletions apps/admin-x-framework/test/unit/api/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {type Action, actionsAreGroupable, getActionTitle} from '../../../src/api/actions';

const baseAction = (overrides: Partial<Action> = {}): Action => ({
id: 'action-1',
resource_id: 'resource-1',
resource_type: 'post',
actor_id: 'actor-1',
actor_type: 'user',
event: 'edited',
context: {},
created_at: '2026-07-01T00:00:00.000Z',
...overrides
});

describe('actions api helpers', () => {
describe('actionsAreGroupable', () => {
it('groups repeated actions for the same resource type, resource id, event, and action name', () => {
const action = baseAction({context: {action_name: 'updated'}});
const nextAction = baseAction({id: 'action-2', context: {action_name: 'updated'}});

expect(actionsAreGroupable(action, nextAction)).toBe(true);
});

it('does not group null-resource actions for different resource types', () => {
const action = baseAction({resource_id: null, resource_type: 'security_action', context: {action_name: 'reset_authentication'}});
const nextAction = baseAction({id: 'action-2', resource_id: null, resource_type: 'setting', context: {action_name: 'updated'}});

expect(actionsAreGroupable(action, nextAction)).toBe(false);
});

it('does not group edited actions with different action names', () => {
const action = baseAction({resource_id: null, resource_type: 'security_action', context: {action_name: 'reset_authentication'}});
const nextAction = baseAction({id: 'action-2', resource_id: null, resource_type: 'security_action', context: {action_name: 'rotate_keys'}});

expect(actionsAreGroupable(action, nextAction)).toBe(false);
});
});

describe('getActionTitle', () => {
it('formats reset authentication security actions', () => {
expect(getActionTitle(baseAction({
resource_id: null,
resource_type: 'security_action',
context: {action_name: 'reset_authentication'}
}))).toBe('Security action reset authentication');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,16 @@ const HistoryActionDescription: React.FC<{action: Action}> = ({action}) => {
const {updateRoute} = useRouting();
const contextResource = getContextResource(action);

if (contextResource) {
if (action.resource_type === 'security_action' && action.context?.action_name === 'reset_authentication') {
const apiKeysRotated = typeof action.context.api_keys_rotated === 'number' ? action.context.api_keys_rotated : null;
const usersLocked = typeof action.context.users_locked === 'number' ? action.context.users_locked : null;
const details = [
apiKeysRotated !== null ? `${apiKeysRotated} API ${apiKeysRotated === 1 ? 'key' : 'keys'} rotated` : null,
usersLocked !== null ? `${usersLocked} ${usersLocked === 1 ? 'user' : 'users'} locked` : null
].filter(Boolean);

return <>{details.length ? details.join(', ') : 'Authentication reset'}</>;
} else if (contextResource) {
const {group, key} = contextResource;

return <>
Expand Down
29 changes: 26 additions & 3 deletions apps/admin-x-settings/test/acceptance/advanced/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,38 @@ import {globalDataRequests, mockApi, responseFixtures} from '@tryghost/admin-x-f

test.describe('History', async () => {
test('Browsing history', async ({page}) => {
const securityAction = {
id: '64d62b327ca62600011d0819',
resource_id: null,
resource_type: 'security_action',
actor_id: '1',
actor_type: 'user',
event: 'edited',
context: '{"action_name":"reset_authentication","api_keys_rotated":4,"users_locked":3}',
created_at: '2023-08-11T12:37:02.000Z',
actor: {
id: '1',
name: 'Jamie Larson',
slug: 'main',
image: null
}
};
const actionsResponse = {
...responseFixtures.actions,
actions: [securityAction, ...responseFixtures.actions.actions]
};

const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseActionsFiltered: {
method: 'GET',
path: /\/actions\/.*filter=.*(?:post|event)/,
response: {
...responseFixtures.actions,
actions: responseFixtures.actions.actions.filter(action => action.resource_type !== 'post')
...actionsResponse,
actions: actionsResponse.actions.filter(action => action.resource_type !== 'post')
}
},
browseActionsAll: {method: 'GET', path: /\/actions\//, response: responseFixtures.actions}
browseActionsAll: {method: 'GET', path: /\/actions\//, response: actionsResponse}
}});

await page.goto('/');
Expand All @@ -26,6 +47,8 @@ test.describe('History', async () => {

await expect(historyModal).toHaveText(/Settings edited: Site \(navigation\) 2 times/);
await expect(historyModal).toHaveText(/Page edited: The Clunkers Hall of Shame 2 times/);
await expect(historyModal).toHaveText(/Security action reset authentication: 4 API keys rotated, 3 users locked/);
await expect(historyModal).not.toHaveText(/Security action reset authentication: \(unknown\)/);

expect(lastApiRequests.browseActionsAll?.url).toEqual('http://localhost:5173/ghost/api/admin/actions/?include=actor%2Cresource&limit=200&filter=resource_type%3A-%5Blabel%5D');

Expand Down
13 changes: 12 additions & 1 deletion ghost/core/core/server/models/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@ const Action = ghostBookshelf.Model.extend({
});
},

resourceCandidates() {
const candidates = this.candidates();

const User = ghostBookshelf.registry.models.User;
if (User) {
candidates.push([User, 'security_action']);
}

return candidates;
},

actor() {
return this.morphTo('actor', ['actor_type', 'actor_id'], ...this.candidates());
},

resource() {
return this.morphTo('resource', ['resource_type', 'resource_id'], ...this.candidates());
return this.morphTo('resource', ['resource_type', 'resource_id'], ...this.resourceCandidates());
}
}, {
orderDefaultOptions: function orderDefaultOptions() {
Expand Down
34 changes: 34 additions & 0 deletions ghost/core/test/e2e-api/admin/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const supertest = require('supertest');
const testUtils = require('../../utils');
const localUtils = require('./utils');
const config = require('../../../core/shared/config');
const models = require('../../../core/server/models');

describe('Actions API', function () {
let request;
Expand Down Expand Up @@ -137,4 +138,37 @@ describe('Actions API', function () {
assert.equal(res5.body.actions[0].actor.name, testUtils.DataGenerator.Content.integrations[0].name);
assert.equal(res5.body.actions[0].actor.slug, testUtils.DataGenerator.Content.integrations[0].slug);
});

it('Can request security actions with resource included', async function () {
const actorId = testUtils.DataGenerator.Content.users[0].id;
const action = await models.Action.add({
event: 'edited',
resource_type: 'security_action',
resource_id: null,
actor_type: 'user',
actor_id: actorId,
context: {
action_name: 'reset_authentication',
api_keys_rotated: 1,
users_locked: 1
}
});

const res = await request
.get(localUtils.API.getApiQuery(`actions/?filter=${encodeURIComponent(`id:'${action.id}'`)}&include=actor,resource`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);

localUtils.API.checkResponse(res.body, 'actions');
localUtils.API.checkResponse(res.body.actions[0], 'action');

assert.equal(res.body.actions.length, 1);
assert.equal(res.body.actions[0].resource_type, 'security_action');
assert.equal(res.body.actions[0].resource_id, null);
assert.equal(res.body.actions[0].actor_type, 'user');
assert.equal(res.body.actions[0].actor.id, actorId);
assert.equal(res.body.actions[0].resource, undefined);
});
});
Loading