Access Control
ont-run uses a group-based access control system. You define access groups, then specify which groups can call each function.
Defining access groups
Define access groups at the top level of your ontology:
defineOntology({ accessGroups: { public: { description: 'Unauthenticated users' }, user: { description: 'Authenticated users' }, support: { description: 'Support agents' }, admin: { description: 'Administrators' }, }, // ...});The auth function
The auth function determines which groups a request belongs to. It can also return user identity for row-level access control.
defineOntology({ auth: async (req) => { const token = req.headers.get('Authorization');
// No token = public only if (!token) { return { groups: ['public'] }; }
// Validate token and get user const user = await validateToken(token); if (!user) { return { groups: ['public'] }; }
// Build group list based on user role const groups = ['public', 'user'];
if (user.role === 'support') { groups.push('support'); }
if (user.role === 'admin') { groups.push('support', 'admin'); }
// Return groups AND user identity (for row-level access) return { groups, user: { id: user.id, email: user.email }, }; }, // ...});Key points:
- Return
{ groups: string[], user?: object }for full functionality - Legacy
string[]return is still supported (groups only) - The
userfield enables row-level access withuserContext() - Users can belong to multiple groups
- The function receives the raw
Requestobject
Assigning access to functions
Each function specifies which groups can call it:
functions: { // Anyone can call this healthCheck: { access: ['public', 'user', 'admin'], // ... },
// Only authenticated users getMyProfile: { access: ['user', 'admin'], // ... },
// Only support and admin lookupUser: { access: ['support', 'admin'], // ... },
// Only admin deleteUser: { access: ['admin'], // ... },}A user can call a function if they have any of the listed groups.
How access is enforced
Access is checked at two levels:
1. MCP tool visibility
When an AI agent connects, it only sees tools it has access to:
// User with groups: ['public', 'user']// Sees: healthCheck, getMyProfile// Does NOT see: lookupUser, deleteUser2. Runtime validation
When a function is called (via API or MCP), access is checked again:
// Even if someone tries to call deleteUser directly:// "Access denied to tool 'deleteUser'. Requires: admin"Access in resolvers
Resolvers receive the current user’s groups via context:
export default function getUser( ctx: ResolverContext, args: { userId: string }) { // Check if user is admin if (ctx.accessGroups.includes('admin')) { // Return full user data return getFullUserData(args.userId); }
// Regular users get limited data return getLimitedUserData(args.userId);}Validation
ont-run validates that all access group references exist:
functions: { getUser: { access: ['superadmin'], // Error: group not defined! // ... },}Error message:
Function "getUser" references unknown access group "superadmin".Valid groups: public, user, support, adminSecurity
Access lists are part of the security-critical ontology. Changes require human review:
Ontology changes detected:
Function changes: ~ deleteUser Access: [admin] -> [support, admin]This prevents AI agents from escalating privileges by modifying access lists.
Row-level access control
Group-based access controls which functions users can call. For row-level access (e.g., “users can only edit their own posts”), use userContext():
import { defineOntology, userContext, z } from 'ont-run';import editPost from './resolvers/editPost.js';
defineOntology({ // Auth must return user identity auth: async (req) => { const user = await validateToken(req); return { groups: user ? ['user'] : ['public'], user: user ? { id: user.id, email: user.email } : undefined, }; },
functions: { editPost: { description: 'Edit a post', access: ['user'], entities: ['Post'], inputs: z.object({ postId: z.string(), title: z.string(), // Injected from auth - hidden from callers currentUser: userContext(z.object({ id: z.string(), email: z.string(), })), }), resolver: editPost, }, },});In the resolver:
export default async function editPost( ctx: ResolverContext, args: { postId: string; title: string; currentUser: { id: string; email: string } }) { const post = await db.posts.findById(args.postId);
// Row-level check if (args.currentUser.id !== post.authorId) { throw new Error('Not authorized to edit this post'); }
return db.posts.update(args.postId, { title: args.title });}Key points about userContext():
- Fields are injected from auth’s
userreturn value - Fields are hidden from public API/MCP schemas
- Fields are type-safe in resolvers
- The review UI shows which functions use user context
OAuth and JWT integration
The auth function works seamlessly with OAuth 2.0, JWT, and any authentication provider. Since you have full control over token validation, you can integrate with Auth0, Clerk, Supabase, or any identity provider.
JWT example
import { jwtVerify } from 'jose';
defineOntology({ auth: async (req) => { const token = req.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return { groups: ['public'] }; }
try { // Verify JWT with your public key const { payload } = await jwtVerify(token, publicKey, { issuer: 'https://auth.example.com', audience: 'your-api', });
// Map JWT claims to access groups const groups = ['public', 'user']; if (payload.role === 'admin') { groups.push('admin'); }
return { groups, user: { id: payload.sub, email: payload.email }, }; } catch { return { groups: ['public'] }; } },});Auth0 example
import { auth } from 'express-oauth2-jwt-bearer';
defineOntology({ auth: async (req) => { const token = req.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return { groups: ['public'] };
// Verify with Auth0 const decoded = await verifyAuth0Token(token);
// Map Auth0 permissions/scopes to groups const groups = ['public']; if (decoded.permissions?.includes('read:users')) { groups.push('user'); } if (decoded.permissions?.includes('admin:all')) { groups.push('admin'); }
return { groups, user: { id: decoded.sub } }; },});MCP OAuth compatibility
This design is fully compatible with the MCP OAuth specification. MCP clients send Authorization: Bearer <token> headers, which your auth function validates. No additional configuration is needed.
Development mode
For local development, you can use simple tokens:
auth: async (req) => { const token = req.headers.get('Authorization');
// Simple dev tokens if (process.env.NODE_ENV === 'development') { if (token === 'dev-admin') return { groups: ['admin', 'user', 'public'] }; if (token === 'dev-user') return { groups: ['user', 'public'] }; return { groups: ['public'] }; }
// Production: real JWT validation return validateProductionToken(req);},Best practices
- Principle of least privilege: Start with minimal access, add as needed
- Hierarchical groups: Admins should include all lower groups
- Explicit public: If something should be public, list
publicexplicitly - Separate concerns: Use different groups for different capabilities (view vs edit)
- Use userContext for ownership: Combine groups (who can call) with userContext (who owns the resource)