Tutorial: Externalizing Permissions (File-Based Store)
This tutorial follows the Integrating Audit Logging guide. We will now replace the hardcoded InMemoryPermissionStore
with a FilePermissionStore
that reads role-to-permission mappings from an external JSON file.
Prerequisites:
- Completion of the previous Audit Logging Integration Tutorial. Your server should be using Auth0 for identity and potentially roles, and sending audit logs externally.
To decouple permission definitions from the application code by loading them from a permissions.json
file, making permissions easier to manage and update (with a server restart).
Step 12: Create the Permissions File
We need a structured file to define which permissions are granted to which roles. JSON is a simple, common format for this.
Create permissions.json
Create a new file named permissions.json
in the root of your my-governed-mcp-app
project (same level as package.json
and tsconfig.json
).
Populate it with permissions that align with the roles potentially coming from your Auth0 setup (or the test roles if you skipped the Auth0 Role Store).
{
"admin": [
"tool:callHello",
"tool:callSensitive",
"resource:read:*", // Example wildcard permission
"audit:read" // Example specific permission
],
"user": [
"tool:callHello"
],
"viewer": [ // Example of another role
"resource:read:public/*"
]
}
Make sure the roles defined here (admin
, user
, viewer
, etc.) match the roles you expect to receive from your Auth0RoleStore
(or your previous InMemoryRoleStore
if you didn’t implement the Auth0 one). The permissions (tool:callHello
, etc.) should match those derived by the defaultDerivePermission
function (or your custom one).
Step 13: Implement FilePermissionStore
This class implements the PermissionStore
interface. It reads the permissions.json
file during initialization and uses the loaded data to answer permission checks.
Create src/permissions/file-permission-store.ts
Create a new directory src/permissions
and place the following code inside file-permission-store.ts
.
import { PermissionStore, OperationContext, Logger } from '@ithena-one/mcp-governance'; // Adjust path
import fs from 'node:fs'; // Node.js file system module
import path from 'node:path'; // Node.js path module
interface FileStoreConfig {
filePath: string; // Path to the permissions JSON file
logger?: Logger; // Optional logger instance
}
export class FilePermissionStore implements PermissionStore {
private readonly config: FileStoreConfig;
private readonly logger: Logger;
private permissionsByRole: Map<string, Set<string>> = new Map(); // Store permissions efficiently
constructor(config: FileStoreConfig) {
if (!config.filePath) {
throw new Error('Permissions file path must be provided.');
}
this.config = config;
this.logger = config.logger || console;
this.logger.info?.(`FilePermissionStore configured with path: ${this.config.filePath}`);
}
// Load permissions during initialization
async initialize(): Promise<void> {
this.logger.info?.(`Initializing FilePermissionStore: Loading permissions from ${this.config.filePath}`);
try {
// Resolve the absolute path relative to the CWD where the script is run
const absolutePath = path.resolve(this.config.filePath);
this.logger.debug?.(`Resolved absolute path: ${absolutePath}`);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Permissions file not found at ${absolutePath}`);
}
const fileContent = fs.readFileSync(absolutePath, 'utf-8');
const parsedPermissions: Record<string, string[]> = JSON.parse(fileContent);
// Clear existing permissions and load new ones into the Map/Set structure
this.permissionsByRole.clear();
for (const [role, permissions] of Object.entries(parsedPermissions)) {
if (Array.isArray(permissions)) {
this.permissionsByRole.set(role, new Set(permissions));
} else {
this.logger.warn?.(`Invalid permissions format for role "${role}" in file ${this.config.filePath}. Expected array.`);
}
}
this.logger.info?.(`Successfully loaded permissions for ${this.permissionsByRole.size} roles from ${this.config.filePath}`);
} catch (error: any) {
this.logger.error?.(`Failed to load or parse permissions file: ${this.config.filePath}`, { error: error.message });
// Throw error to prevent server from starting with invalid permissions
throw new Error(`Failed to initialize FilePermissionStore: ${error.message}`);
}
}
async hasPermission(role: string, permission: string, opCtx: OperationContext): Promise<boolean> {
const scopedLogger = opCtx.logger || this.logger;
const permissions = this.permissionsByRole.get(role);
if (!permissions) {
scopedLogger.debug?.(`Permission check: Role "${role}" not found in permissions file. Denying permission "${permission}".`);
return false; // Role not defined in the file
}
// Check for exact match or wildcard '*'
const granted = permissions.has(permission) || permissions.has('*');
scopedLogger.debug?.(`Permission check: Role "${role}", Permission "${permission}". Granted: ${granted}`);
return granted;
}
// Optional: Shutdown is likely a no-op unless implementing file watching
// async shutdown(): Promise<void> { this.logger.info?.('FilePermissionStore shutting down.'); }
}
This implementation reads the file once during the initialize
phase. Changes to the file while the server is running will not be reflected until the server restarts and initialize
is called again.
Step 14: Update governed-app.ts
to Use FilePermissionStore
We configure the GovernedServer
to use our new file-based store instead of the in-memory one.
Modify src/governed-app.ts
:
// src/governed-app.ts
import { Server as BaseServer } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import process from 'node:process';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import path from 'node:path'; // Import path
// --- Import Governance SDK, Auth0, Audit & NEW Permission Module ---
import { /* ... other governance imports ... */ PermissionStore /* Import base type */ } from '@ithena-one/mcp-governance';
import { Auth0IdentityResolver } from './auth/auth0-identity-resolver.js';
import { Auth0RoleStore } from './auth/auth0-role-store.js';
// import { SplunkAuditLogStore } from './auditing/splunk-audit-log-store.js'; // Or Datadog
import { FilePermissionStore } from './permissions/file-permission-store.js'; // <-- IMPORT
console.log('Starting Governed MCP Server (File Permissions)...');
// --- 1. Base Server & Components ---
const baseServer = new BaseServer({ name: "MyGovernedMCPServer-FilePerms", version: "1.0.0" }, { capabilities: { tools: {} } });
const logger = new ConsoleLogger({}, 'debug');
// const auditStore = new SplunkAuditLogStore({...}); // Or Datadog, or Console
const auditStore = new ConsoleAuditLogStore(); // Keep console for simplicity here
// --- 2. Configure and Instantiate Components ---
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'YOUR_AUTH0_DOMAIN';
const API_AUDIENCE = process.env.AUTH0_API_AUDIENCE || 'YOUR_API_AUDIENCE';
const AUTH0_ROLES_CLAIM = process.env.AUTH0_ROLES_CLAIM || 'https://myapp.example.com/roles';
const identityResolver = new Auth0IdentityResolver({ auth0Domain: AUTH0_DOMAIN, apiAudience: API_AUDIENCE, logger: logger });
const roleStore = new Auth0RoleStore({ rolesClaim: AUTH0_ROLES_CLAIM, logger: logger });
// --- Instantiate FilePermissionStore ---
// Path relative to where you RUN node (usually project root)
const permissionsFilePath = './permissions.json';
const permissionStore = new FilePermissionStore({
filePath: permissionsFilePath,
logger: logger
});
// --- End FilePermissionStore Instantiation ---
// --- 3. GovernedServer Configuration ---
const governedServerOptions: GovernedServerOptions = {
logger: logger,
auditStore: auditStore,
identityResolver: identityResolver,
roleStore: roleStore,
permissionStore: permissionStore, // <-- Use the file store instance
enableRbac: true,
auditDeniedRequests: true,
serviceIdentifier: "governed-app-file-perms",
// ... other options
};
// --- 4. Create GovernedServer instance ---
const governedServer = new GovernedServer(baseServer, governedServerOptions);
logger.info('GovernedServer created with FilePermissionStore');
// --- 5. Define Tool Schemas ---
const helloToolSchema = z.object({ /* ... */ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callHello'), params: z.object({ arguments: z.object({ greeting: z.string().optional().default('Hello') }).optional().default({ greeting: 'Hello' }), _meta: z.any().optional() }) });
const sensitiveToolSchema = z.object({ /* ... */ jsonrpc: z.literal("2.0"), id: z.union([z.string(), z.number()]), method: z.literal('tools/callSensitive'), params: z.object({ arguments: z.any().optional(), _meta: z.any().optional() }) });
// --- 6. Register Handlers ---
governedServer.setRequestHandler(helloToolSchema, async (request, extra: GovernedRequestHandlerExtra) => { /* ... */ const scopedLogger = extra.logger || logger; const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id; scopedLogger.info(`[Handler] Executing callHello for identity: ${identityId || 'anonymous'} with roles: ${JSON.stringify(extra.roles)}. EventID: ${extra.eventId}`); const greeting = request.params?.arguments?.greeting || 'DefaultGreeting'; const responseText = `${greeting} ${identityId || 'World'} from governed server!`; return { content: [{ type: 'text', text: responseText }] }; });
governedServer.setRequestHandler(sensitiveToolSchema, async (request, extra: GovernedRequestHandlerExtra) => { /* ... */ const identityId = typeof extra.identity === 'string' ? extra.identity : extra.identity?.id; const scopedLogger = extra.logger || logger; scopedLogger.info(`[Handler] Executing callSensitive for identity: ${identityId}`, { roles: extra.roles }); return { content: [{ type: 'text', text: `Sensitive data accessed by ${identityId}` }] }; });
logger.info('Handlers registered.');
// --- 7. Connect and Shutdown ---
const transport = new StdioServerTransport();
async function startServer() {
try {
// Initialize is called within connect()
await governedServer.connect(transport);
logger.info("Governed MCP server (File Permissions) started.");
} catch (error) { logger.error("Failed to start server", error as Error); process.exit(1); }
}
const shutdown = async () => { /* ... */ logger.info("Shutting down..."); try { await governedServer.close(); logger.info("Shutdown complete."); process.exit(0); } catch (err) { logger.error("Error during shutdown:", err); process.exit(1); } };
process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);
startServer(); // Call startServer at the end
We removed the InMemoryPermissionStore
and replaced it with an instance of FilePermissionStore
, providing the path to our permissions.json
.
Step 15: Testing with File-Based Permissions
- Verify
permissions.json
: Double-check that the roles and permissions inpermissions.json
match your expectations and the roles coming from Auth0. - Rebuild:
npm run build
- Run:
npm run start
- Watch the startup logs. You should see messages from
FilePermissionStore
indicating it’s loading the file. If there are errors (file not found, invalid JSON), the server might fail to start.
- Watch the startup logs. You should see messages from
- Obtain Auth0 Tokens: Get tokens for users with different roles (e.g., ‘admin’, ‘user’).
- Send Requests (using
curl
or similar): Repeat the RBAC tests from the previous tutorial:- Call
tools/callHello
with a ‘user’ token (should succeed). - Call
tools/callSensitive
with a ‘user’ token (should be denied). - Call
tools/callSensitive
with an ‘admin’ token (should succeed).
- Call
- Modify Permissions:
- Stop the server (Ctrl+C).
- Edit
permissions.json
. For example, remove"tool:callSensitive"
from theadmin
role’s list. - Restart the server:
npm run start
. - Resend the
tools/callSensitive
request with the ‘admin’ token. - It should now be denied, demonstrating that the permissions were reloaded from the file on restart.
RBAC checks now use the rules defined in permissions.json
. Changes to the file require a server restart to take effect. Logs should show the FilePermissionStore
being initialized and used for checks.
Final Code Structure
Your src
directory might now look like:
└── src/
├── auth/
│ ├── auth0-identity-resolver.ts
│ └── auth0-role-store.ts (Optional)
├── auditing/
│ └── ... (splunk/datadog store)
├── permissions/
│ └── file-permission-store.ts (NEW)
└── governed-app.ts (Imports from ./auth, ./auditing, ./permissions)
└── permissions.json (NEW - at project root)
Next Steps & Considerations
You’ve successfully externalized your permission definitions!
- Restarts Required: The biggest limitation of this simple file store is needing a server restart to apply permission changes. For dynamic updates, you’d need file watching or a different storage mechanism.
- Scalability: Managing a very large JSON file can become difficult.
- Complex Permissions: This approach doesn’t easily handle attribute-based access control (ABAC) or resource-specific permissions (e.g., user X can only edit their own posts).
- Error Handling: Add more robust error handling around file access and parsing in a production scenario.
This file-based approach is a good step towards manageable permissions. For more complex or dynamic requirements, consider:
- Database-Backed Store: Store permissions in a database for easier updates via an admin interface.
- Policy Engine (OPA): Use a dedicated engine like Open Policy Agent for highly flexible and dynamic policy evaluation.
You now have a solid setup with externalized identity (Auth0), auditing (Splunk/Datadog), and permissions (File).