.agents/skills/add-admin-api-endpoint/permissions.md
This guide explains how to configure permissions in api-framework controllers, covering all available patterns and best practices.
The api-framework uses a pipeline-based permission system where permissions are handled as one of five request processing stages:
Important: Every controller method MUST explicitly define the permissions property. This is a security requirement that prevents accidental security holes and makes permission handling explicit.
// This will throw an IncorrectUsageError
edit: {
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
// Missing permissions property!
}
true - Default Permission CheckThe most common pattern that delegates to the default permission handler.
edit: {
headers: {
cacheInvalidate: true
},
options: ['include'],
validation: {
options: {
include: {
required: true,
values: ['tags']
}
}
},
permissions: true,
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
}
When to use:
When you set permissions: true, the framework delegates to the default permission handler at ghost/core/core/server/api/endpoints/utils/permissions.js. Here's what happens:
Singular Name Derivation: The handler converts the docName to singular form:
posts → postautomated_emails → automated_emailcategories → category (handles ies → y)Permission Check: It calls the permissions service:
permissions.canThis(frame.options.context)[method][singular](identifier, unsafeAttrs)
For example, with docName: 'posts' and method edit:
permissions.canThis(context).edit.post(postId, unsafeAttrs)
Database Lookup: The permissions service checks the permissions and permissions_roles tables:
action_type matching the method (e.g., edit)object_type matching the singular docName (e.g., post)For the default handler to work, you must have:
Permission records in the permissions table:
INSERT INTO permissions (name, action_type, object_type) VALUES
('Browse posts', 'browse', 'post'),
('Read posts', 'read', 'post'),
('Edit posts', 'edit', 'post'),
('Add posts', 'add', 'post'),
('Delete posts', 'destroy', 'post');
Role-permission mappings in permissions_roles linking permissions to roles like Administrator, Editor, etc.
These are typically added via:
ghost/core/core/server/data/schema/fixtures/fixtures.jsonaddPermissionWithRoles() from ghost/core/core/server/data/migrations/utils/permissions.jsfalse - Skip PermissionsCompletely bypasses the permissions stage.
browse: {
options: ['page', 'limit'],
permissions: false,
query(frame) {
return models.PublicResource.findAll(frame.options);
}
}
When to use:
Warning: Use with caution. Only disable permissions when you're certain the endpoint should be publicly accessible.
Allows complete control over permission validation.
delete: {
options: ['id'],
permissions: async function(frame) {
// Ensure user is authenticated
if (!frame.user || !frame.user.id) {
const UnauthorizedError = require('@tryghost/errors').UnauthorizedError;
return Promise.reject(new UnauthorizedError({
message: 'You must be logged in to perform this action'
}));
}
// Only the owner or an admin can delete
const resource = await models.Resource.findOne({id: frame.options.id});
if (resource.get('author_id') !== frame.user.id && frame.user.role !== 'admin') {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You do not have permission to delete this resource'
}));
}
return Promise.resolve();
},
query(frame) {
return models.Resource.destroy(frame.options);
}
}
When to use:
Combines default permission handling with configuration options and hooks.
edit: {
options: ['include'],
permissions: {
unsafeAttrs: ['author', 'status'],
before: async function(frame) {
// Load additional user data needed for permission checks
frame.user.permissions = await loadUserPermissions(frame.user.id);
}
},
query(frame) {
return models.Post.edit(frame.data, frame.options);
}
}
When to use:
Permission handlers receive a frame object containing complete request context:
Frame {
// Request data
original: {}, // Original untransformed input
options: {}, // Query/URL parameters
data: {}, // Request body
// User context
user: {}, // Logged-in user object
// File uploads
file: {}, // Single uploaded file
files: [], // Multiple uploaded files
// API context
apiType: String, // 'content' or 'admin'
docName: String, // Endpoint name (e.g., 'posts')
method: String, // Method name (e.g., 'browse', 'add', 'edit')
// HTTP context (added by HTTP wrapper)
context: {
api_key: {}, // API key information
user: userId, // User ID or null
integration: {}, // Integration details
member: {} // Member information or null
}
}
When using Pattern 4, these properties are available:
unsafeAttrs (Array)Specifies attributes that require special permission handling.
permissions: {
unsafeAttrs: ['author', 'visibility', 'status']
}
These attributes are passed to the permission handler for additional validation. Use this for fields that only certain users should be able to modify (e.g., only admins can change the author of a post).
before (Function)A hook that runs before the default permission handler.
permissions: {
before: async function(frame) {
// Prepare data needed for permission checks
const membership = await loadMembership(frame.user.id);
frame.user.membershipLevel = membership.level;
}
}
module.exports = {
docName: 'articles',
browse: {
options: ['page', 'limit', 'filter'],
validation: {
options: {
limit: {
values: [10, 25, 50, 100]
}
}
},
permissions: false,
query(frame) {
return models.Article.findPage(frame.options);
}
}
};
module.exports = {
docName: 'posts',
browse: {
options: ['include', 'page', 'limit', 'filter', 'order'],
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: ['include'],
data: ['id', 'slug'],
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options);
}
},
add: {
headers: {
cacheInvalidate: true
},
options: ['include'],
permissions: {
unsafeAttrs: ['author_id']
},
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options);
}
},
edit: {
headers: {
cacheInvalidate: true
},
options: ['include', 'id'],
permissions: {
unsafeAttrs: ['author_id', 'status']
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options);
}
},
destroy: {
headers: {
cacheInvalidate: true
},
options: ['id'],
permissions: true,
statusCode: 204,
query(frame) {
return models.Post.destroy(frame.options);
}
}
};
module.exports = {
docName: 'user_settings',
read: {
options: ['user_id'],
permissions: async function(frame) {
// Users can only read their own settings
if (frame.options.user_id !== frame.user.id) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You can only view your own settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.UserSetting.findOne({user_id: frame.options.user_id});
}
},
edit: {
options: ['user_id'],
permissions: async function(frame) {
// Users can only edit their own settings
if (frame.options.user_id !== frame.user.id) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'You can only edit your own settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.UserSetting.edit(frame.data, frame.options);
}
}
};
module.exports = {
docName: 'admin_settings',
browse: {
permissions: async function(frame) {
const allowedRoles = ['Owner', 'Administrator'];
if (!frame.user || !allowedRoles.includes(frame.user.role)) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'Only administrators can access these settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.AdminSetting.findAll();
}
},
edit: {
permissions: async function(frame) {
// Only the owner can edit admin settings
if (!frame.user || frame.user.role !== 'Owner') {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
return Promise.reject(new NoPermissionError({
message: 'Only the site owner can modify these settings'
}));
}
return Promise.resolve();
},
query(frame) {
return models.AdminSetting.edit(frame.data, frame.options);
}
}
};
module.exports = {
docName: 'premium_content',
read: {
options: ['id'],
permissions: {
before: async function(frame) {
// Load user's subscription status
if (frame.user) {
const subscription = await models.Subscription.findOne({
user_id: frame.user.id
});
frame.user.subscription = subscription;
}
}
},
async query(frame) {
// The query can now use frame.user.subscription
const content = await models.Content.findOne({id: frame.options.id});
if (content.get('premium') && !frame.user?.subscription?.active) {
const NoPermissionError = require('@tryghost/errors').NoPermissionError;
throw new NoPermissionError({
message: 'Premium subscription required'
});
}
return content;
}
}
};
// Good - explicit about being public
permissions: false
// Good - explicit about requiring auth
permissions: true
// Bad - missing permissions (will throw error)
// permissions: undefined
| Scenario | Pattern |
|---|---|
| Public endpoint | permissions: false |
| Standard authenticated CRUD | permissions: true |
| Need unsafe attrs tracking | permissions: { unsafeAttrs: [...] } |
| Complex custom logic | permissions: async function(frame) {...} |
| Need pre-processing | permissions: { before: async function(frame) {...} } |
Permission functions should only check permissions, not perform business logic:
// Good - only checks permissions
permissions: async function(frame) {
if (!frame.user || frame.user.role !== 'admin') {
throw new NoPermissionError();
}
}
// Bad - mixes permission check with business logic
permissions: async function(frame) {
if (!frame.user) throw new NoPermissionError();
// Don't do this in permissions!
frame.data.processed = true;
await sendNotification(frame.user);
}
permissions: async function(frame) {
if (!frame.user) {
throw new UnauthorizedError({
message: 'Please log in to access this resource'
});
}
if (frame.user.role !== 'admin') {
throw new NoPermissionError({
message: 'Administrator access required for this operation'
});
}
}
When resources belong to specific users, always verify ownership:
permissions: async function(frame) {
const resource = await models.Resource.findOne({id: frame.options.id});
if (!resource) {
throw new NotFoundError({message: 'Resource not found'});
}
const isOwner = resource.get('user_id') === frame.user.id;
const isAdmin = frame.user.role === 'admin';
if (!isOwner && !isAdmin) {
throw new NoPermissionError({
message: 'You do not have permission to access this resource'
});
}
}
unsafeAttrs for Sensitive FieldsMark fields that require elevated permissions:
permissions: {
unsafeAttrs: [
'author_id', // Only admins should change authorship
'status', // Publishing requires special permission
'visibility', // Changing visibility is restricted
'featured' // Only editors can feature content
]
}
Use appropriate error types from @tryghost/errors:
const {
UnauthorizedError,
NoPermissionError,
NotFoundError
} = require('@tryghost/errors');
When creating a new API endpoint that uses the default permission handler (permissions: true), you need to add permissions to the database. Ghost provides utilities to make this easy.
Import the permission utilities from ghost/core/core/server/data/migrations/utils:
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
// ghost/core/core/server/data/migrations/versions/X.X/YYYY-MM-DD-HH-MM-SS-add-myresource-permissions.js
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');
module.exports = combineTransactionalMigrations(
addPermissionWithRoles({
name: 'Browse my resources',
action: 'browse',
object: 'my_resource' // Singular form of docName
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Read my resources',
action: 'read',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Edit my resources',
action: 'edit',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Add my resources',
action: 'add',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
]),
addPermissionWithRoles({
name: 'Delete my resources',
action: 'destroy',
object: 'my_resource'
}, [
'Administrator',
'Admin Integration'
])
);
Common roles you can assign permissions to:
'Browse automated emails'browse, read, edit, add, destroydocName - automated_email (not automated_emails)To make an endpoint accessible only to administrators (not editors, authors, etc.), only assign permissions to:
AdministratorAdmin IntegrationaddPermissionWithRoles({
name: 'Browse sensitive data',
action: 'browse',
object: 'sensitive_data'
}, [
'Administrator',
'Admin Integration'
])