Error Handling & Reasons
Asgardian provides comprehensive error handling capabilities that help you provide meaningful feedback to users when permissions are denied. This includes the ability to attach reasons to denial rules and throw structured errors.
Overview
When defining permission rules, you can provide explanations for why certain actions are denied. This helps in:
- User Experience: Providing clear feedback about why an action was rejected
- Debugging: Understanding permission logic during development
- Auditing: Logging detailed information about access attempts
- API Responses: Returning structured error messages
Adding Reasons to Rules
You can chain .reason()
method after both can()
and cannot()
rules to provide explanations:
import { createAbility } from '@nordic-ui/asgardian'
const ability = createAbility<'publish' | 'archive', 'Post' | 'Comment'>()
ability
// read published posts
.can('read', 'Post', { published: true })
.reason('Public posts are readable by everyone')
// update own posts
.can('update', 'Post', { authorId: 123 })
.reason('Authors can edit their own posts')
// delete any posts
.cannot('delete', 'Post')
.reason('Deletion not allowed for security reasons')
// publish non-draft posts
.cannot('publish', 'Post', { status: { $ne: 'draft'} })
.reason('Cannot publish non-draft posts')
// update locked posts
.cannot('update', 'Post', { locked: true })
.reason('Post is locked for editing')
Conditional Reasons
Reasons work with conditions, allowing you to provide context-specific explanations:
const ability = createAbility<never, 'Post' | 'Comment'>()
ability
// manage own posts
.can('manage', 'Post', { authorId: 123 })
.reason('Full access to own posts')
// read published posts
.can('read', 'Post', { published: true })
.reason('Published posts are publicly accessible')
// delete posts with comments
.cannot('delete', 'Post', { hasComments: true })
.reason('Cannot delete posts that have comments')
// update published posts by others
.cannot('update', 'Post', { published: true, authorId: { $ne: 123 } })
.reason('Cannot update published posts by other authors')
// create comments on locked post
.cannot('create', 'Comment', { post: { locked: true } })
.reason('Comments are disabled on locked posts')
Retrieving Reasons
Use getReason()
to get the explanation for why an action was allowed or denied:
const ability = createAbility()
ability
.can('read', 'Post').reason('Reading is always allowed')
.cannot('delete', 'Post').reason('Deletion not allowed')
.cannot('update', 'Post', { archived: true }).reason('Cannot modify archived content')
// Get reasons for both allowed and denied actions
const readReason = ability.getReason('read', 'Post')
console.log(readReason) // "Reading is always allowed"
const deleteReason = ability.getReason('delete', 'Post')
console.log(deleteReason) // "Deletion not allowed"
const updateReason = ability.getReason('update', 'Post', { archived: true })
console.log(updateReason) // "Cannot modify archived content"
const unknownReason = ability.getReason('publish', 'Post')
console.log(unknownReason) // undefined (no matching rule)
Reason Precedence
When multiple rules match, the most recent (last defined) rule’s reason is returned:
const ability = createAbility()
ability
.can('update', 'Post').reason('Updates allowed by default')
.cannot('update', 'Post', { locked: true }).reason('Post is locked')
// If post is locked, the more specific denial reason is returned
const reason = ability.getReason('update', 'Post', { locked: true })
console.log(reason) // "Post is locked"
// If post is not locked, the general allow reason applies
const generalReason = ability.getReason('update', 'Post', { locked: false })
console.log(generalReason) // "Updates allowed by default"
ForbiddenError
Asgardian provides a ForbiddenError class for structured error handling:
import { createAbility, ForbiddenError } from '@nordic-ui/asgardian'
const ability = createAbility()
ability.cannot('delete', 'Post').reason('Deletion forbidden by policy')
// Manual error creation
throw new ForbiddenError('Custom error message')
// Error properties
try {
throw new ForbiddenError('Access denied')
} catch (error) {
console.log(error.name) // "ForbiddenError"
console.log(error.message) // "Access denied"
console.log(error instanceof Error) // true
console.log(error instanceof ForbiddenError) // true
}
Throwing Errors
Use throwIfNotAllowed()
to automatically throw ForbiddenError
when permissions are denied:
const ability = createAbility()
ability
.can('read', 'Post').reason('Reading is permitted')
.cannot('delete', 'Post').reason('Deletion not allowed')
// This won't throw (action is allowed)
ability.throwIfNotAllowed('read', 'Post')
// This will throw ForbiddenError with reason
try {
ability.throwIfNotAllowed('delete', 'Post')
} catch (error) {
if (error instanceof ForbiddenError) {
console.log(error.message) // "Deletion not allowed"
}
}
With Conditions
throwIfNotAllowed()
works with conditional permissions:
const ability = createAbility()
ability
.can('update', 'Post', { authorId: 123 }).reason('Authors can edit their posts')
.cannot('update', 'Post', { locked: true }).reason('Post is locked for editing')
const post = { id: 1, authorId: 123, locked: true }
try {
ability.throwIfNotAllowed('update', 'Post', post)
} catch (error) {
console.log(error.message) // "Post is locked for editing"
}
Default Error Message
When no reason is provided, a default message is used:
const ability = createAbility()
// No explicit rules, so action is not allowed by default
try {
ability.throwIfNotAllowed('delete', 'Post')
} catch (error) {
console.log(error.message) // "Access denied"
}
Practical Examples
API Route Protection
// Express.js route handler
app.delete('/api/posts/:id', async (req, res) => {
try {
const ability = await getUserAbility(req.user)
const post = await getPost(req.params.id)
ability.throwIfNotAllowed('delete', 'Post', post)
await deletePost(req.params.id)
res.json({ success: true })
} catch (error) {
if (error instanceof ForbiddenError) {
res.status(403).json({
error: 'Permission denied',
reason: error.message
})
} else {
res.status(500).json({ error: 'Internal server error' })
}
}
})
Form Validation
const validatePostUpdate = (post: Post, user: User) => {
const ability = createUserAbility(user)
const validations = []
if (ability.notAllowed('update', 'Post', post)) {
const reason = ability.getReason('update', 'Post', post)
validations.push({
field: 'general',
message: reason || 'You cannot update this post'
})
}
if (ability.notAllowed('publish', 'Post', post)) {
const reason = ability.getReason('publish', 'Post', post)
validations.push({
field: 'published',
message: reason || 'You cannot publish this post'
})
}
return validations
}
User-Friendly Messages
const ability = createAbility()
ability
.can('read', 'Post', { published: true })
.reason('Published posts are available to all users')
.cannot('read', 'Post', { published: false })
.reason('This post is not yet published')
.can('update', 'Post', { authorId: 123 })
.reason('You have full editing rights to your posts')
.cannot('update', 'Post', { authorId: { $ne: 123 } })
.reason('You can only edit your own posts')
.cannot('delete', 'Post', { hasComments: true })
.reason('Cannot delete posts with comments. Please delete comments first.')
.cannot('publish', 'Post', { status: 'draft', reviewStatus: { $ne: 'approved' } })
.reason('Posts must be reviewed before publishing')
Logging and Auditing
const auditPermissionCheck = (action: string, resource: string, user: User, data?: any) => {
const ability = getUserAbility(user)
const isAllowed = ability.isAllowed(action, resource, data)
const reason = ability.getReason(action, resource, data)
logger.info('Permission check', {
userId: user.id,
action,
resource,
isAllowed,
reason,
timestamp: new Date().toISOString(),
data: data ? { id: data.id } : null
})
if (!isAllowed) {
// Could also store in database for audit trail
await saveAuditLog({
userId: user.id,
action: 'PERMISSION_DENIED',
details: { action, resource, reason }
})
}
return isAllowed
}
Best Practices
1. Provide Clear, Actionable Reasons
// ✅ Good - Clear and actionable for both allow and deny
ability
.can('read', 'Post', { published: true })
.reason('Published content is publicly accessible')
.cannot('publish', 'Post', { status: 'draft' })
.reason('Post must be reviewed before publishing')
// ❌ Less helpful - Vague
ability
.can('read', 'Post')
.reason('Allowed')
.cannot('publish', 'Post', { status: 'draft' })
.reason('Cannot publish')
2. Use Consistent Messaging
// ✅ Good - Consistent pattern
const MESSAGES = {
PUBLIC_ACCESS: 'This content is publicly accessible',
OWNER_ONLY: 'You can only modify your own content',
LOCKED_RESOURCE: 'This resource is locked and cannot be modified',
INVALID_STATE: (state: string) => `Action not allowed when resource is ${state}`,
UPGRADE_REQUIRED: 'Upgrade your account to access this feature'
}
ability
.can('read', 'Post', { published: true })
.reason(MESSAGES.PUBLIC_ACCESS)
.cannot('delete', 'Post', { authorId: { $ne: 123 } })
.reason(MESSAGES.OWNER_ONLY)
.cannot('update', 'Post', { locked: true })
.reason(MESSAGES.LOCKED_RESOURCE)
3. Handle Errors Gracefully
const handlePermissionError = (error: unknown, defaultMessage = 'Access denied') => {
if (error instanceof ForbiddenError) {
return {
type: 'PERMISSION_ERROR',
message: error.message,
code: 'FORBIDDEN'
}
}
return {
type: 'UNKNOWN_ERROR',
message: defaultMessage,
code: 'INTERNAL_ERROR'
}
}
4. Don’t Expose Sensitive Information
// ✅ Good - Generic but helpful
ability
.can('read', 'Post', { public: true })
.reason('Public posts are accessible to all users')
.cannot('read', 'Post', { private: true, authorId: { $ne: user.id } })
.reason('This post is private')
// ❌ Bad - Exposes internal logic
ability.cannot('read', 'Post', { private: true, authorId: { $ne: user.id } })
.reason('Post is private and authorId 456 does not match current user 123')
Summary
Asgardian’s error handling and reasoning system provides:
- Contextual Feedback: Attach reasons to both allow and deny rules for better user experience
- Structured Errors: Use
ForbiddenError
for consistent error handling - Flexible API: Choose between checking reasons manually or throwing errors automatically
- Development Support: Clear error messages help with debugging and development
This system enables you to build applications with excellent permission feedback while maintaining security and usability.