Role Menu Permission Service — Full Architecture
What Problem Are We Solving?
In any application with multiple backend services, every team ends up writing the same code over and over:
- "Is this user an Admin? Can they access this route?"
- "Which menu items should a mobile app show this user?"
- "Does this user have permission to create, read, update, or delete?"
Without a centralized solution, each service has its own checkRole() or hasPermission() logic, its own tables, its own bugs. Changes ripple everywhere.
The Role Menu Permission Service is a dedicated microservice that owns all of this logic — one authoritative source of truth for roles, menus, and permissions across your entire platform.
Big Picture — What This Service Does
Core idea: Clients talk REST. Backend services talk gRPC. Everything goes through one permission service.
Core Concepts — The Building Blocks
Before diving into architecture, understand the three core entities:
🏷️ Role
A named collection of permissions. Examples: super-admin, store-manager, customer, read-only-viewer.
🔑 Permission
A specific action on a resource. Modeled as resource:action:scope:
orders:create:own— create your own ordersproducts:delete:any— delete any productusers:read:any— view any user profile
📋 Menu Item
A navigation entry in a web/mobile app. It's tied to a permission — if the user lacks the permission, the menu item disappears.
Full System Architecture
Request Flow — Step by Step
Flow 1: Web/Mobile App Loads a Menu
Flow 2: Backend Service Checks a Permission via gRPC
API Design
REST API (for Web & Mobile Clients)
| Method | Endpoint | Description | Auth |
|---|---|---|---|
GET | /v1/me/menus?platform=web | Get current user's menu tree | JWT |
GET | /v1/me/permissions | Get all effective permissions | JWT |
GET | /v1/roles | List all roles | Admin |
POST | /v1/roles | Create a new role | Admin |
PUT | /v1/roles/:id | Update role details | Admin |
DELETE | /v1/roles/:id | Delete a role | Admin |
POST | /v1/roles/:id/permissions | Assign permissions to role | Admin |
DELETE | /v1/roles/:id/permissions/:permId | Revoke a permission | Admin |
GET | /v1/permissions | List all permissions | Admin |
POST | /v1/permissions | Create permission entry | Admin |
GET | /v1/menus | List all menu items | Admin |
POST | /v1/menus | Create menu item | Admin |
PUT | /v1/menus/:id | Update menu item | Admin |
DELETE | /v1/menus/:id | Delete menu item | Admin |
POST | /v1/users/:id/roles | Assign role to user | Admin |
DELETE | /v1/users/:id/roles/:roleId | Remove role from user | Admin |
Sample REST Responses
GET /v1/me/menus?platform=web
{
"success": true,
"data": {
"platform": "web",
"menus": [
{
"id": "m-001",
"title": "Dashboard",
"icon": "dashboard",
"route": "/dashboard",
"sortOrder": 1,
"children": []
},
{
"id": "m-002",
"title": "Orders",
"icon": "shopping-cart",
"route": "/orders",
"sortOrder": 2,
"children": [
{
"id": "m-003",
"title": "All Orders",
"icon": "list",
"route": "/orders/list",
"sortOrder": 1,
"children": []
},
{
"id": "m-004",
"title": "Create Order",
"icon": "plus",
"route": "/orders/create",
"sortOrder": 2,
"children": []
}
]
}
]
}
}gRPC API (for Internal Backend Services)
This is the high-performance, low-latency path used by all your microservices.
syntax = "proto3";
package permissions.v1;
// ─────────────────────────────────────────────
// The Permission Service contract
// All internal services use this to check access
// ─────────────────────────────────────────────
service PermissionService {
// Check if a user has a specific permission
rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse);
// Batch check multiple permissions at once
rpc CheckPermissions(CheckPermissionsRequest) returns (CheckPermissionsResponse);
// Get all permissions a user holds (for middleware caching)
rpc GetUserPermissions(GetUserPermissionsRequest) returns (GetUserPermissionsResponse);
// Validate a JWT and return user info + roles in one call
rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
}
// ─── Request / Response Messages ───
message CheckPermissionRequest {
string user_id = 1;
string resource = 2; // e.g. "orders"
string action = 3; // e.g. "create", "read", "update", "delete"
string scope = 4; // e.g. "own", "any" (optional)
}
message CheckPermissionResponse {
bool allowed = 1;
string role = 2; // which role granted this
string reason = 3; // human-readable explanation
}
message CheckPermissionsRequest {
string user_id = 1;
repeated Permission permissions = 2;
}
message Permission {
string resource = 1;
string action = 2;
string scope = 3;
}
message CheckPermissionsResponse {
repeated PermissionResult results = 1;
}
message PermissionResult {
string resource = 1;
string action = 2;
bool allowed = 3;
}
message GetUserPermissionsRequest {
string user_id = 1;
}
message GetUserPermissionsResponse {
string user_id = 1;
repeated string roles = 2;
repeated string permissions = 3; // ["orders:create:any", "products:read:any"]
}
message ValidateTokenRequest {
string jwt_token = 1;
}
message ValidateTokenResponse {
bool valid = 1;
string user_id = 2;
string email = 3;
repeated string roles = 4;
repeated string permissions = 5;
int64 expires_at = 6;
}Database Schema
-- ─────────────────────────────────────────────
-- Core Tables
-- ─────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'super-admin'
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- system roles can't be deleted
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource VARCHAR(100) NOT NULL, -- e.g. 'orders', 'products'
action VARCHAR(50) NOT NULL, -- e.g. 'create', 'read', 'update', 'delete'
scope VARCHAR(50) NOT NULL DEFAULT 'any', -- 'own' or 'any'
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(resource, action, scope)
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(200) NOT NULL,
icon VARCHAR(100),
route VARCHAR(500),
platform VARCHAR(20) NOT NULL CHECK (platform IN ('web', 'mobile', 'both')),
sort_order INT NOT NULL DEFAULT 0,
parent_id UUID REFERENCES menu_items(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by UUID, -- admin user id
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID NOT NULL, -- foreign key to your User Service
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID, -- admin who made the assignment
expires_at TIMESTAMPTZ, -- optional: temporary role assignment
PRIMARY KEY (user_id, role_id)
);
-- ─────────────────────────────────────────────
-- Audit Trail
-- ─────────────────────────────────────────────
CREATE TABLE permission_audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
action VARCHAR(50) NOT NULL, -- 'ROLE_ASSIGNED', 'PERMISSION_CHECKED'
resource VARCHAR(100),
result VARCHAR(20), -- 'ALLOWED', 'DENIED'
ip_address INET,
user_agent TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─────────────────────────────────────────────
-- Indexes for Performance
-- ─────────────────────────────────────────────
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
CREATE INDEX idx_menu_items_platform ON menu_items(platform, is_active, sort_order);
CREATE INDEX idx_menu_items_parent_id ON menu_items(parent_id);
CREATE INDEX idx_audit_log_user_id ON permission_audit_log(user_id, created_at);Caching Strategy
One of the most important parts of this service is caching — permission checks happen on EVERY request across every service, so they must be near-instant.
Cache Key Patterns
| Cache Key | Content | TTL |
|---|---|---|
perm:{userId}:all | All permission slugs for user | 5 min |
perm:{userId}:{resource}:{action} | Single permission check result | 5 min |
menu:{platform}:{roleIds sorted} | Full menu tree for roles | 5 min |
role:{roleId}:perms | All permissions for one role | 10 min |
user:{userId}:roles | All role IDs for a user | 5 min |
Cache Invalidation Rules
Whenever an admin changes anything via the REST API, the service instantly deletes related cache entries:
Role updated → Delete: role:{id}:perms
Delete: perm:*:{roleId}:* (all users with that role)
Permission changed → Delete: role:{roleId}:perms
Delete: perm:{userId}:all (all affected users)
User role changed → Delete: user:{userId}:roles
Delete: perm:{userId}:*
Delete: menu:*:{userId}:*Integration — How Other Services Use This
Option A: Middleware Approach (Recommended)
Each service runs a thin permission middleware that calls the Permission Service via gRPC on every incoming request. The result is attached to the request context.
// permission-middleware.js
// Drop this into any Express/Fastify service
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const PERM_SERVICE_ADDR =
process.env.PERMISSION_SERVICE_GRPC_ADDR || "permission-svc:50051";
const packageDef = protoLoader.loadSync("./permission.proto", {
keepCase: true,
longs: String,
enums: String,
});
const { PermissionService } =
grpc.loadPackageDefinition(packageDef)["permissions.v1"];
const client = new PermissionService(
PERM_SERVICE_ADDR,
grpc.credentials.createInsecure()
);
/**
* Factory: create a middleware that checks a specific permission
*
* Usage: router.post('/orders', requirePermission('orders', 'create'), handler)
*/
function requirePermission(resource, action, scope = "any") {
return (req, res, next) => {
const userId = req.user?.id; // set by your JWT middleware
if (!userId) return res.status(401).json({ error: "Unauthenticated" });
client.CheckPermission(
{ user_id: userId, resource, action, scope },
(err, response) => {
if (err) {
console.error("Permission service unavailable:", err.message);
return res
.status(503)
.json({ error: "Authorization service unavailable" });
}
if (!response.allowed) {
return res.status(403).json({
error: "Forbidden",
reason: response.reason,
});
}
req.permission = response; // available in route handler
next();
}
);
};
}
module.exports = { requirePermission };Using it in any service:
// orders-service/routes/orders.js
const { requirePermission } = require("./permission-middleware");
router.get("/orders", requirePermission("orders", "read"), async (req, res) => {
const orders = await OrderModel.findAll();
res.json({ orders });
});
router.post(
"/orders",
requirePermission("orders", "create"),
async (req, res) => {
const order = await OrderModel.create(req.body);
res.json({ order });
}
);
router.delete(
"/orders/:id",
requirePermission("orders", "delete", "any"),
async (req, res) => {
await OrderModel.deleteById(req.params.id);
res.json({ success: true });
}
);Option B: SDK Approach
Publish a shared npm package @yourorg/permission-sdk that any Node.js service can install:
npm install @yourorg/permission-sdk// Usage in any service
const { PermissionClient } = require("@yourorg/permission-sdk");
const perms = new PermissionClient({
serviceAddr: process.env.PERMISSION_SERVICE_ADDR,
// Enables local in-memory cache for 30s to reduce gRPC calls
localCacheTtl: 30_000,
});
// One-liner check
const allowed = await perms.can(userId, "orders:create:any");
// Middleware factory
app.post("/products", perms.require("products:create"), handler);
// Batch check
const results = await perms.checkMany(userId, [
"orders:read",
"products:update",
"users:delete",
]);
// { 'orders:read': true, 'products:update': false, 'users:delete': false }Mobile & Web Frontend Integration
How a Web App Uses the Menu API
// React hook: useNavMenu.js
import { useEffect, useState } from "react";
import axios from "axios";
export function useNavMenu() {
const [menus, setMenus] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
axios
.get("/api/v1/me/menus?platform=web", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
})
.then((res) => setMenus(res.data.data.menus))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
return { menus, loading };
}
// NavBar.jsx
function NavBar() {
const { menus, loading } = useNavMenu();
if (loading) return <Skeleton />;
return (
<nav>
{menus.map((item) => (
<NavItem key={item.id} item={item} />
))}
</nav>
);
}How a Mobile App Uses the Menu API
// iOS — Swift example
struct MenuView: View {
@State private var menus: [MenuItem] = []
var body: some View {
List(menus, id: \.id) { item in
NavigationLink(destination: RouteView(route: item.route)) {
Label(item.title, systemImage: item.icon)
}
}
.onAppear { fetchMenus() }
}
func fetchMenus() {
let url = URL(string: "\(API_BASE)/v1/me/menus?platform=mobile")!
var request = URLRequest(url: url)
request.setValue("Bearer \(AuthManager.shared.token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, _, _ in
if let data = data,
let response = try? JSONDecoder().decode(MenuResponse.self, from: data) {
DispatchQueue.main.async { self.menus = response.data.menus }
}
}.resume()
}
}Security Architecture
Security Rules
| Rule | Why |
|---|---|
| JWT secret is only known to Auth Service and API Gateway | Permission Service never issues tokens — it only evaluates permissions |
| gRPC endpoint is not exposed externally | Only internal Kubernetes services can reach port 50051 |
| System roles cannot be deleted | is_system = true roles like super-admin are protected |
| All admin actions are audit-logged | You can trace who changed what permission and when |
| Cache TTL is max 5 minutes | Role changes propagate within 5 minutes without manual intervention |
| Permission check failures are safe-fail | If the service is down, requests are denied (fail-closed), not allowed |
High Availability & Scalability
Deployment Checklist
- ✅ Horizontal scaling: Run 3+ pods behind a load balancer
- ✅ PostgreSQL read replicas: All SELECT queries hit replicas; writes go to primary
- ✅ Redis Cluster: High-availability caching with automatic failover
- ✅ Health checks:
/health/liveand/health/readyendpoints - ✅ Graceful shutdown: Drain in-flight requests before pod termination
- ✅ Circuit breaker: If Redis is unavailable, fall back to DB; if DB is down, deny all
- ✅ Rate limiting: Admin API is rate-limited to prevent abuse
Observability
Key Metrics to Monitor
| Metric | Alert Threshold |
|---|---|
perm_check_latency_p99 | > 50ms |
perm_check_denied_rate | Spike in denials (possible attack) |
cache_hit_rate | < 80% (cache might be invalid) |
grpc_error_rate | > 1% |
menu_api_response_time | > 200ms |
db_query_time_p99 | > 100ms |
Environment Variables & Configuration
# ─── Service Identity ───
SERVICE_NAME=role-menu-permission-service
SERVICE_VERSION=1.0.0
# ─── REST API ───
PORT=3000
NODE_ENV=production
# ─── gRPC ───
GRPC_PORT=50051
GRPC_MAX_CONNECTIONS=100
# ─── PostgreSQL ───
DB_HOST=postgres-primary
DB_PORT=5432
DB_NAME=permissions
DB_USER=perm_svc_user
DB_PASSWORD=<secret>
DB_REPLICA_HOST=postgres-replica
# ─── Redis ───
REDIS_HOST=redis-cluster
REDIS_PORT=6379
REDIS_PASSWORD=<secret>
CACHE_DEFAULT_TTL=300
# ─── JWT ───
JWT_PUBLIC_KEY=<rsa-public-key> # Only needed to verify tokens
# Never store the private key here
# ─── Security ───
ADMIN_API_RATE_LIMIT=100 # requests per minute
GRPC_TLS_ENABLED=true
# ─── Observability ───
LOG_LEVEL=info
JAEGER_ENDPOINT=http://jaeger:14268/api/traces
PROMETHEUS_PORT=9090Project Folder Structure
role-menu-permission-service/
├── src/
│ ├── api/
│ │ ├── rest/
│ │ │ ├── routes/
│ │ │ │ ├── roles.routes.js # POST/GET/DELETE /roles
│ │ │ │ ├── permissions.routes.js # CRUD /permissions
│ │ │ │ ├── menus.routes.js # CRUD /menus
│ │ │ │ └── me.routes.js # GET /me/menus, /me/permissions
│ │ │ ├── middleware/
│ │ │ │ ├── auth.middleware.js # JWT verification
│ │ │ │ ├── require-admin.js # Admin role gate
│ │ │ │ └── validate.js # Request body validation
│ │ │ └── app.js # Express/Fastify setup
│ │ │
│ │ └── grpc/
│ │ ├── server.js # gRPC server bootstrap
│ │ ├── handlers/
│ │ │ ├── checkPermission.js # CheckPermission RPC
│ │ │ ├── checkPermissions.js # Batch check RPC
│ │ │ ├── getUserPermissions.js # GetUserPermissions RPC
│ │ │ └── validateToken.js # ValidateToken RPC
│ │ └── proto/
│ │ └── permission.proto # gRPC contract file
│ │
│ ├── services/
│ │ ├── role.service.js # Role CRUD business logic
│ │ ├── permission.service.js # Permission evaluation engine
│ │ └── menu.service.js # Dynamic menu tree builder
│ │
│ ├── repositories/
│ │ ├── role.repository.js # DB queries for roles
│ │ ├── permission.repository.js # DB queries for permissions
│ │ ├── menu.repository.js # DB queries for menu items
│ │ └── audit.repository.js # Audit log writes
│ │
│ ├── cache/
│ │ ├── redis.client.js # Redis connection + helpers
│ │ ├── keys.js # Cache key templates
│ │ └── invalidator.js # Cache invalidation logic
│ │
│ ├── db/
│ │ ├── connection.js # PostgreSQL pool setup
│ │ └── migrations/ # SQL migration files
│ │
│ ├── config/
│ │ └── env.js # Validated env config
│ │
│ └── index.js # Entry point: start REST + gRPC
│
├── sdk/ # Publishable client SDK
│ ├── index.js
│ ├── permission-client.js
│ └── middleware.js
│
├── proto/
│ └── permission.proto # Shared proto file
│
├── tests/
│ ├── unit/
│ └── integration/
│
├── docker-compose.yml
├── Dockerfile
├── package.json
└── README.mdSummary — Decision Map
Quick Reference
| Topic | Decision |
|---|---|
| Protocol: clients | REST over HTTPS |
| Protocol: services | gRPC (internal only, not exposed) |
| Caching | Redis, 5 min TTL, invalidate on change |
| Database | PostgreSQL (primary + read replica) |
| Auth model | RBAC — Roles → Permissions → Resources |
| Menu model | Hierarchical tree, filtered by user roles |
| Integration | Drop-in middleware OR SDK npm package |
| Fail behavior | Fail-closed — deny if permission service is down |
| Scalability | Horizontal pod scaling, Redis cluster |
| Audit | Full immutable audit log of all access decisions |
