Skip to main content

Fine-Grained Access Control New

Available from v1.3.0-beta

Beyond endpoint-level authentication, Arkos provides fine-grained access control for complex business logic scenarios where role-based permissions alone aren't sufficient. This allows you to implement conditional access, data filtering, and hierarchical permissions directly within your application controllers and interceptor middlewares.

What is Fine-Grained Access Control (FGAC)?

Fine-grained access control lets you check specific permissions within your business logic, beyond what route-level authentication provides. Instead of just "can this user access this endpoint?", you can ask "can this specific user perform this specific action on this specific resource?"

When You Need Fine-Grained Control

Standard RBAC works great for endpoints, but complex applications need more:

// Route-level: "Can user access /api/posts?"
// Fine-grained: "Can this user edit posts in this specific category?"
// Fine-grained: "Can this user see all posts or only published ones?"
// Fine-grained: "Should this user see all events or only events in their region?"

Common Use Cases:

  • Content Management: Authors edit their own posts, editors manage their assigned categories
  • Multi-tenant Systems: Users access only their organization's data
  • Hierarchical Permissions: Managers see team data, directors see department data
  • Conditional Access: Different data views based on user role or context

Core Implementation: authService.permission()

Fine-grained access control centers around authService.permission(), which creates permission checker functions for specific actions and resources.

Basic Syntax

const permissionChecker = authService.permission(action, resource, accessControl?);
const hasPermission = await permissionChecker(user);

Parameters:

  • action: The specific action (e.g., "View", "Edit", "Approve", "Export")
  • resource: The resource name in kebab-case (e.g., "blog-post", "event", "user-profile")
  • accessControl: Required for unknown modules beyong any prisma models, auth and file-upload

Critical: Initialization Timing

authService.permission() must be called during application initialization, not during request handling:

// ✅ CORRECT: Called at module level during app startup
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
const canEditPost = authService.permission("Edit", "blog-post");

export const beforeUpdateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const hasPermission = await canEditPost(req.user);
if (!hasPermission) throw new AppError("Not authorized", 403);

next();
},
];

// ❌ WRONG: Called during request handling
export const beforeUpdateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
// This will throw an error!
const canEditPost = authService.permission("Edit", "blog-post");
const hasPermission = await canEditPost(req.user);
},
];

Auto-Discovery Integration

All permissions created with authService.permission() and also those added into authConfigs.accessControl are automatically available through the /api/auth-actions endpoint, helping frontend developers discover available actions.

Permission Organization Patterns

Organize related permissions into objects for better maintainability and discoverability:

Creating Permission Objects

// src/modules/blog-post/blog-post.auth.ts
import { AuthConfigs } from "arkos/auth";
import { authService } from "arkos/services";

/**
* Blog post permissions for content management system
*/
export const blogPostPermissions = {
/** View published and draft posts */
canViewAll: authService.permission("ViewAll", "blog-post"),
/** View only published posts */
canViewPublished: authService.permission("ViewPublished", "blog-post"),
/** Create new blog posts */
canCreate: authService.permission("Create", "blog-post"),
/** Edit any blog post */
canEditAny: authService.permission("EditAny", "blog-post"),
/** Edit only own blog posts */
canEditOwn: authService.permission("EditOwn", "blog-post"),
/** Publish/unpublish blog posts */
canPublish: authService.permission("Publish", "blog-post"),
/** Share blog posts via social media */
canShare: authService.permission("Share", "blog-post"),
/** Export blog posts to various formats */
canExport: authService.permission("Export", "blog-post"),
};

const blogPostAuthConfigs: AuthConfigs = {
authenticationControl: {
// Only define actions that will be use into routes
// Whether custom or built-in routes.
ViewAll: true,
ViewPublished: false, // Public access, no auth required
Create: true,
EditAny: true,
EditOwn: true,
Publish: true,
Share: true,
Export: true,
},
accessControl: {
ViewAll: {
// Only add roles if using static authentication
roles: ["Author", "Editor", "Admin"],
name: "View All Posts",
description: "View both published and draft blog posts",
},
ViewPublished: {
roles: ["Guest", "Author", "Editor", "Admin"],
name: "View Published Posts",
description: "View publicly published blog posts",
},
Create: {
roles: ["Author", "Editor", "Admin"],
name: "Create Posts",
description: "Create new blog posts",
},
EditAny: {
roles: ["Editor", "Admin"],
name: "Edit Any Post",
description: "Edit any blog post regardless of author",
},
EditOwn: {
roles: ["Author", "Editor", "Admin"],
name: "Edit Own Posts",
description: "Edit blog posts authored by the user",
},
Publish: {
roles: ["Editor", "Admin"],
name: "Publish Posts",
description: "Publish or unpublish blog posts",
},
Share: {
roles: ["Author", "Editor", "Admin"],
name: "Share Posts",
description: "Share blog posts via social media platforms",
},
Export: {
roles: ["Editor", "Admin"],
name: "Export Posts",
description:
"Export blog posts to various formats (PDF, Word, etc.)",
},
},
};

export default blogPostAuthConfigs;
IMPORTANT

Noticed the roles fields into the accessControl actions, those are only required if you are using Static Authentication because on Dynamic Authentication roles and permissions are managed on database level.

Resource Naming Conventions

  • Use kebab-case for resource names: "blog-post", "user-profile", "event-category"
  • Match your model names but in kebab-case: BlogPost → "blog-post"
  • Be specific when needed: "blog-post", "blog-category", "blog-comment"

Common Use Cases

The most common pattern is adding fine-grained permissions to Arkos's auto-generated CRUD endpoints using interceptor middlewares.

Simple Permission Check in Interceptors

// Then: Create permission checkers (Arkos resolves from authConfigs automatically)
// src/modules/blog-post/blog-post.auth.ts
import { authService } from "arkos/services";
import { AuthConfigs } from "arkos/auth";

export const blogPostPermissions = {
canEditOwn: authService.permission("EditOwn", "blog-post"),
canEditAny: authService.permission("EditAny", "blog-post"),
};

const blogPostAuthConfigs: AuthConfigs = {
authenticationControl: {
Update: true,
EditOwn: true,
EditAny: true,
},
accessControl: {
Update: ["Author", "Editor", "Admin"],
EditOwn: {
roles: ["Author", "Editor", "Admin"],
name: "Edit Own Posts",
description: "Edit blog posts authored by the user",
},
EditAny: {
roles: ["Editor", "Admin"],
name: "Edit Any Post",
description: "Edit any blog post regardless of author",
},
},
};

export default blogPostAuthConfigs;

Finally use in interceptor middlewares:

// src/modules/blog-post/blog-post.middlewares.ts
import { AppError } from "arkos/error-handler";
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import { blogPostPermissions } from "./blog-post.auth";
import blogPostService from "./blog-post.service";

export const beforeUpdateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;
const postId = req.params.id;

// Get the post first to check ownership
const post = await blogPostService.findOne({ id: postId });
if (!post) throw new AppError("Blog post not found", 404);

// Check permissions with context
const [canEditAny, canEditOwn] = await Promise.all([
blogPostPermissions.canEditAny(user),
blogPostPermissions.canEditOwn(user),
]);

// Determine if user can edit this specific post
const canEditThisPost =
canEditAny || (canEditOwn && post.authorId === user.id);

if (!canEditThisPost)
throw new AppError(
"You don't have permission to edit this post",
403,
{},
"CannotEditThisPost"
);

// Additional business logic checks
if (post.status === "published" && !canEditAny)
throw new AppError(
"You cannot edit published posts",
403,
{},
"CannotEditPublishedPost"
);

next();
},
];

Data Filtering in Interceptors

Modify queries based on user permissions in beforeFindMany:

// src/modules/blog-post/blog-post.middlewares.ts
export const beforeFindMany = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;
if (!user) return next();

// Check what level of access user has
const [canViewAll, canEditOwn] = await Promise.all([
blogPostPermissions.canViewAll(user),
blogPostPermissions.canEditOwn(user),
]);

if (canViewAll)
// Admin/Editor: can see all posts, no query modification needed
return next();

// Build query restrictions based on permissions
const baseQuery = req.query || {};

if (canEditOwn) {
// Author: can see published posts + their own drafts
req.query = {
...baseQuery,
OR: [{ status: "published" }, { authorId: user.id }],
};
} else {
// Regular user: only published posts
req.query = {
...baseQuery,
status: "published",
};
}

next();
},
];

Response Modification with Permissions

Add permission flags to responses in afterFindOne:

export const afterFindOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;
const post = res.locals.data.data;

if (!user || !post) return next();

// Check multiple permissions efficiently
const [canEdit, canDelete, canPublish, canShare] = await Promise.all([
blogPostPermissions
.canEditAny(user)
.then(
(canEditAny) =>
canEditAny ||
((await blogPostPermissions.canEditOwn(user)) &&
post.authorId === user.id)
),
blogPostPermissions.canDelete(user),
blogPostPermissions.canPublish(user),
blogPostPermissions.canShare(user),
]);

// Add permission metadata to response
res.locals.data.data = {
...post,
_permissions: {
canEdit,
canDelete,
canPublish,
canShare,
},
};

next();
},
];

Custom Business Logic with Fine-Grained Control

For operations beyond standard CRUD, implement custom controllers and routes with fine-grained permissions.

Custom Controller with Permission Checks

// src/modules/blog-post/blog-post.controller.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import { BaseController } from "arkos/controllers";
import { AppError } from "arkos/error-handler";
import { blogPostPermissions } from "./blog-post.auth";
import blogPostService from "./blog-post.service";

const postNotFoundError = new AppError(
"Post not found",
404,
{},
"PostNotFound"
);

class BlogPostController extends BaseController {
async sharePost(req: ArkosRequest, res: ArkosResponse) {
const user = req.user;
const postId = req.params.id;
const { platform, message } = req.body;

// Check share permission
const canShare = await blogPostPermissions.canShare(user);
if (!canShare)
throw new AppError("You don't have permission to share posts", 403);

// Get post and verify it's shareable
const post = await blogPostService.findOne({ id: postId });
if (!post) throw postNotFoundError;

if (post.status !== "published")
throw new AppError(
"Only published posts can be shared",
400,
{},
"CannotShareUnpublishedPost"
);

// Perform share operation
const shareResult = await this.socialMediaService.share(post, {
platform,
message,
sharedBy: user.id,
});

// Log the share activity (custom logic)
await this.auditService.logActivity({
action: "share_post",
userId: user.id,
resourceId: postId,
metadata: { platform, shareId: shareResult.id },
});

res.json({
success: true,
data: shareResult,
message: "Post shared successfully",
});
}

async getBlogPostAnalytics(req: ArkosRequest, res: ArkosResponse) {
const user = req.user;
const postId = req.params.id;

// Check if user can view analytics (custom business logic)
const [canEditAny, canEditOwn] = await Promise.all([
blogPostPermissions.canEditAny(user),
blogPostPermissions.canEditOwn(user),
]);

const post = await blogPostService.findOne({ id: postId });
if (!post) throw postNotFoundError;

// Authors can see analytics for their own posts, editors for any post
const canViewAnalytics =
canEditAny || (canEditOwn && post.authorId === user.id);

if (!canViewAnalytics)
throw new AppError(
"You don't have permission to view analytics for this post",
403
);

const analytics = await this.analyticsService.getPostAnalytics(postId);

res.json({ success: true, data: analytics });
}

async exportPosts(req: ArkosRequest, res: ArkosResponse) {
const user = req.user;
const { format = "csv", filters = {} } = req.body;

// Check export permission
const canExport = await blogPostPermissions.canExport(user);
if (!canExport) {
throw new AppError(
"You don't have permission to export posts",
403
);
}

// Apply user-specific filtering based on permissions
const [canViewAll] = await Promise.all([
blogPostPermissions.canViewAll(user),
]);

let queryFilters = filters;
if (!canViewAll) {
// Restrict to user's own posts if they can't view all
queryFilters = {
...filters,
authorId: user.id,
};
}

const posts = await blogPostService.findMany({ where: queryFilters });
const exportData = await this.exportService.export(posts, format);

res.setHeader(
"Content-Disposition",
`attachment; filename="posts.${format}"`
);
res.setHeader("Content-Type", this.getContentType(format));
res.send(exportData);
}
}

const blogPostController = new BlogPostController("blog-post");

export default blogPostController;

Custom Routes with Fine-Grained Control

// src/modules/blog-post/blog-post.router.ts
import { Router } from "express";
import { authService } from "arkos/services";
import { catchAsync } from "arkos/error-handler";
import blogPostController from "./blog-post.controller";
import { blogPostPermissions } from "./blog-post.auth";

const router = Router();

// Share post route
router.post(
"/:id/share",
authService.authenticate,
// You could also use authService.handleAccessControl for simpler cases
catchAsync(blogPostController.sharePost)
);

// Analytics route - custom permission logic is in controller
router.get(
"/:id/analytics",
authService.authenticate,
catchAsync(blogPostController.getBlogPostAnalytics)
);

// Export route
router.post(
"/export",
authService.authenticate,
catchAsync(blogPostController.exportPosts)
);

// Bulk operations with direct permission check in middleware
router.patch(
"/bulk-approve",
authService.authenticate,
// You can also prefer to use authService.handleAccessControl
// When implementing authentication into routers
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const canApprove = await blogPostPermissions.canPublish(req.user);
if (!canApprove)
throw new AppError(
"You don't have permission to approve posts",
403
);

next();
},
catchAsync(blogPostController.bulkApprove)
);

export default router;
tip

As a best practice you can prefer to use authService.handleAccessControl instead, when adding authentication into custom routes checkout more about at Adding Authentication Into Custom Routers Section.

Advanced Implementation Patterns

Hierarchical Permission Logic

Implement cascading permissions where higher-level permissions include lower-level ones:

// src/modules/event/event.auth.ts
export const eventPermissions = {
/** View all events system-wide */
canViewGlobal: authService.permission("ViewGlobal", "event"),
/** View events in assigned regions */
canViewRegional: authService.permission("ViewRegional", "event"),
/** View events in own city only */
canViewLocal: authService.permission("ViewLocal", "event"),
/** Manage events in assigned regions */
canManageRegional: authService.permission("ManageRegional", "event"),
/** Manage events in own city only */
canManageLocal: authService.permission("ManageLocal", "event"),
};
// src/modules/event/event.middlewares.ts
export const beforeFindMany = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;

// Check permissions in hierarchical order
const [canViewGlobal, canViewRegional, canViewLocal] =
await Promise.all([
eventPermissions.canViewGlobal(user),
eventPermissions.canViewRegional(user),
eventPermissions.canViewLocal(user),
]);

let whereClause = {};

if (canViewGlobal) {
// Global access: no restrictions
whereClause = {};
} else if (canViewRegional) {
// Regional access: events in user's assigned regions
const userProfile = await userProfileService.findOne({
userId: user.id,
});
whereClause = {
region: {
in: userProfile.assignedRegions,
},
};
} else if (canViewLocal) {
// Local access: events in user's city only
const userProfile = await userProfileService.findOne({
userId: user.id,
});
whereClause = {
city: userProfile.city,
};
} else {
throw new AppError("You don't have permission to view events", 403);
}

// Apply the where clause to the query
req.query = {
...req.query,
where: {
...req.query.where,
...whereClause,
},
};

next();
},
];

Permission Caching Pattern

Cache permission results for expensive checks within the same request:

export const beforeFindOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;

// Cache permission results on the request object
if (!(req as any).userPermissions) {
(req as any).userPermissions = {
canEditOwn: await blogPostPermissions.canEditOwn(user),
canEditAny: await blogPostPermissions.canEditAny(user),
canViewAll: await blogPostPermissions.canViewAll(user),
};
}

next();
},
];

export const afterFindOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const post = res.locals.data.data;
const permissions = (req as any).userPermissions;

if (!post || !permissions) return next();

// Use cached permissions
const canEditThisPost =
permissions.canEditAny ||
(permissions.canEditOwn && post.authorId === req.user.id);

res.locals.data.data = {
...post,
_permissions: {
canEdit: canEditThisPost,
canViewAll: permissions.canViewAll,
},
};

next();
},
];

Static vs Dynamic Mode Considerations

Usage in Both Modes

Fine-grained permissions work identically in both static and dynamic modes. The accessControl parameter is not required for either mode when working with known modules:

// Works in both Static and Dynamic mode - no accessControl parameter needed
// Just define it under .auth.ts and it will work just fine
export const blogPostPermissions = {
canEditOwn: authService.permission("EditOwn", "blog-post"),
canEditAny: authService.permission("EditAny", "blog-post"),
canShare: authService.permission("Share", "blog-post"),
canExport: authService.permission("Export", "blog-post"),
};

Differences in Both Modes Using FGAC

When using Static Authentication and wants to fine-grain access control of a know module, it is recommended to define the actions under the .auth.ts file under the accessControl object see This Example, by doing it you will not need to pass the accessControl object as a third parameter.

The same applies to Dynamic Authentication, the only difference is that the roles field under accessControl.SomeAction won't be used here, but it is also higly recommended to define those there when wanting to fine-grain access control because it will help later on auto documenting your actions for frontend developers see Auto-Discovery Integration Section.

When accessControl is Required

The accessControl parameter is only required for unknown modules (modules that are not Prisma models, auth, or file-upload):

// Only needed for unknown modules
const canCustomAction = authService.permission(
"CustomAction",
"unknown-module",
{
CustomAction: ["Admin", "Manager"],
}
);

Known Modules

If the action you're checking exists in the .auth.ts file of a known module (any Prisma model, auth, or file-upload), you're good to go without the third parameter. Learn more about Known vs Unknown Modules.

Migration Considerations

Using authService.permission() makes migration between Static and Dynamic modes seamless since the API remains identical:

// This code works in both modes without changes!
const canEditPost = await blogPostPermissions.canEditOwn(user);

// Avoid direct role checks, as they break in Dynamic mode:
// ❌ if (user.role === "Author") // Breaks in Dynamic mode
// ✅ if (await blogPostPermissions.canEditOwn(user)) // Works in both modes

Performance and Best Practices

Optimization Strategies

1. Batch Permission Checks: Use Promise.all for multiple permissions:

// ✅ Efficient: Parallel execution
const [canEdit, canDelete, canShare] = await Promise.all([
blogPostPermissions.canEditOwn(user),
blogPostPermissions.canDelete(user),
blogPostPermissions.canShare(user),
]);

// ❌ Inefficient: Sequential execution
const canEdit = await blogPostPermissions.canEditOwn(user);
const canDelete = await blogPostPermissions.canDelete(user);
const canShare = await blogPostPermissions.canShare(user);

2. Cache Permission Results: For expensive permission checks within the same request:

export const beforeFindOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;

// Cache permission results on the request object
if (!req.userPermissions) {
req.userPermissions = {
canEditOwn: await blogPostPermissions.canEditOwn(user),
canEditAny: await blogPostPermissions.canEditAny(user),
canShare: await blogPostPermissions.canShare(user),
};
}

next();
},
];

Error Handling Patterns

export const beforeUpdateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;
const canEdit = await blogPostPermissions.canEditOwn(user);

if (!canEdit) {
throw new AppError(
"You don't have permission to edit blog posts",
403
);
}

next();
},
];

Integration Patterns

Combining with Interceptor Middlewares

Use fine-grained permissions in interceptor middlewares for automatic enforcement:

// src/modules/blog-post/blog-post.middlewares.ts
import { AppError } from "arkos/error-handler";
import { blogPostPermissions } from "./blog-post.auth";

export const beforeCreateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const user = req.user;
if (!user) return next();

const canCreate = await blogPostPermissions.canCreate(user);
if (!canCreate) {
throw new AppError(
"Insufficient permissions to create blog posts",
403
);
}

// Add author ID automatically
req.body.authorId = user.id;
next();
},
];

Common Gotchas and Troubleshooting

Initialization Timing Issues

// ❌ This will throw an error during request handling
export const beforeCreateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const canCreate = authService.permission("Create", "blog-post"); // Error!
},
];

// ✅ Initialize permissions at module level
const canCreatePosts = authService.permission("Create", "blog-post");

export const beforeCreateOne = [
async (req: ArkosRequest, res: ArkosResponse, next: ArkosNextFunction) => {
const hasPermission = await canCreatePosts(req.user);
},
];

Resource Naming Mistakes

// ❌ Wrong: camelCase or PascalCase
const canEdit = authService.permission("Edit", "blogPost");
const canEdit = authService.permission("Edit", "BlogPost");

// ✅ Correct: kebab-case
const canEdit = authService.permission("Edit", "blog-post");

User Object Requirements

// Ensure user object has required fields for permission checks
const hasPermission = await somePermission(user);

// User must have:
// - id field (for Dynamic mode database queries)
// - isSuperUser field (super users bypass all checks)
// - role/roles fields (for Static mode, if not super user)

Next Steps

Fine-grained access control gives you the flexibility to implement complex business rules while maintaining the simplicity of Arkos's automated systems. Use interceptor middlewares to enhance auto-generated endpoints with permission checks, and custom controllers with routes for unique business logic that goes beyond standard CRUD operations.