Skip to content

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 orders
  • products:delete:any — delete any product
  • users: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)

MethodEndpointDescriptionAuth
GET/v1/me/menus?platform=webGet current user's menu treeJWT
GET/v1/me/permissionsGet all effective permissionsJWT
GET/v1/rolesList all rolesAdmin
POST/v1/rolesCreate a new roleAdmin
PUT/v1/roles/:idUpdate role detailsAdmin
DELETE/v1/roles/:idDelete a roleAdmin
POST/v1/roles/:id/permissionsAssign permissions to roleAdmin
DELETE/v1/roles/:id/permissions/:permIdRevoke a permissionAdmin
GET/v1/permissionsList all permissionsAdmin
POST/v1/permissionsCreate permission entryAdmin
GET/v1/menusList all menu itemsAdmin
POST/v1/menusCreate menu itemAdmin
PUT/v1/menus/:idUpdate menu itemAdmin
DELETE/v1/menus/:idDelete menu itemAdmin
POST/v1/users/:id/rolesAssign role to userAdmin
DELETE/v1/users/:id/roles/:roleIdRemove role from userAdmin

Sample REST Responses

GET /v1/me/menus?platform=web

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

proto
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

sql
-- ─────────────────────────────────────────────
-- 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 KeyContentTTL
perm:{userId}:allAll permission slugs for user5 min
perm:{userId}:{resource}:{action}Single permission check result5 min
menu:{platform}:{roleIds sorted}Full menu tree for roles5 min
role:{roleId}:permsAll permissions for one role10 min
user:{userId}:rolesAll role IDs for a user5 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

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.

js
// 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:

js
// 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:

bash
npm install @yourorg/permission-sdk
js
// 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

js
// 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

swift
// 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

RuleWhy
JWT secret is only known to Auth Service and API GatewayPermission Service never issues tokens — it only evaluates permissions
gRPC endpoint is not exposed externallyOnly internal Kubernetes services can reach port 50051
System roles cannot be deletedis_system = true roles like super-admin are protected
All admin actions are audit-loggedYou can trace who changed what permission and when
Cache TTL is max 5 minutesRole changes propagate within 5 minutes without manual intervention
Permission check failures are safe-failIf 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/live and /health/ready endpoints
  • 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

MetricAlert Threshold
perm_check_latency_p99> 50ms
perm_check_denied_rateSpike 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

bash
# ─── 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=9090

Project 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.md

Summary — Decision Map

Quick Reference

TopicDecision
Protocol: clientsREST over HTTPS
Protocol: servicesgRPC (internal only, not exposed)
CachingRedis, 5 min TTL, invalidate on change
DatabasePostgreSQL (primary + read replica)
Auth modelRBAC — Roles → Permissions → Resources
Menu modelHierarchical tree, filtered by user roles
IntegrationDrop-in middleware OR SDK npm package
Fail behaviorFail-closed — deny if permission service is down
ScalabilityHorizontal pod scaling, Redis cluster
AuditFull immutable audit log of all access decisions

Released under the ISC License.