Skip to Content
DocsTutorialTutorial - Externalizing Permissions (File-Based Store)

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:

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).

permissions.json
{ "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.

src/permissions/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
// 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

  1. Verify permissions.json: Double-check that the roles and permissions in permissions.json match your expectations and the roles coming from Auth0.
  2. Rebuild: npm run build
  3. 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.
  4. Obtain Auth0 Tokens: Get tokens for users with different roles (e.g., ‘admin’, ‘user’).
  5. 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).
  6. Modify Permissions:
    • Stop the server (Ctrl+C).
    • Edit permissions.json. For example, remove "tool:callSensitive" from the admin 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).

Last updated on