This powerful trio of tools—OpenAPI Specification (OAS), Open Policy Agent (OPA), and swagger-jsdoc— form a cohesive strategy for API design, security, and documentation. In this post we will take a deep dive into how these technologies work together to create a unified, automated, and maintainable API ecosystem.
OpenAPI Specification (OAS): The OpenAPI Specification is a language-agnostic standard for describing RESTful APIs in a machine-readable format (JSON or YAML). It's the modern successor to the Swagger Specification and serves as the single source of truth for your API's design. An OAS document defines everything from endpoints and request/response schemas to authentication methods and server URLs. Its real-world applications are vast, from generating interactive documentation with Swagger UI to creating client SDKs and server stubs automatically.
Open Policy Agent (OPA): OPA is an open-source, general-purpose policy engine that allows you to decouple policy decisions from your application's business logic. It provides a declarative policy language called Rego, which enables you to define policies in a human-readable format. For API authorization, OPA acts as a centralized decision-making service. Instead of scattering authorization logic throughout your codebase, your application or API gateway sends a request to OPA, which then evaluates the request against its policies and returns a simple "allow" or "deny" decision.
swagger-jsdoc: This is a powerful library for Node.js developers that simplifies the creation of an OpenAPI document. It works by parsing JSDoc comments in your source code and generating a corresponding OpenAPI JSON or YAML file. This "code-first" or "annotation-first" approach ensures that your documentation remains in sync with your actual code, reducing the risk of outdated or inaccurate documentation.
swagger-jsdoc centralizes API documentation by generating a single, standardized OAS file. This file then becomes the input for a whole ecosystem of tools. Instead of maintaining separate documentation files, you maintain JSDoc comments directly alongside the code they describe, ensuring consistency and accuracy.
OPA's core value is its ability to separate authorization logic from the application code. This is important for several reasons:
Policies can be updated and deployed independently of the application, which is crucial for agile security practices.
A security team can write and manage policies without needing to touch the application's source code, and
Developers can focus on building features without getting bogged down in complex authorization rules.
OPA is essentially a rule engine that can be used to enforce rules declared in Rego. The rules language is very versatile and we can create rules for RBAC, for specifying network egress, for container policies and even for ci/cd policies. See Rego Playground
A policy is a set of rules that governs the behaviour of a software service. That policy could describe rate-limits, names of trusted servers, the clusters an application should be deployed to, permitted network routes, or accounts a user can withdraw money from.
Open Policy Agent lets you decouple policy from the software service so that the people responsible for policy can read, write, analyze, version, distribute, and in general manage policy separate from the service itself*
Policies can also make decisions based on each other. Policies almost always consist of multiple rules that refer to other rules (possibly authored by different groups)
When OPA evaluates policies it binds data provided in the query to a global variable called input.
Data can be loaded into OPA from outside world using push or pull interfaces that operate synchronously or asynchronously with respect to policy evaluation. We refer to all data loaded into OPA from the outside world as base documents.
We refer to the values generated by rules (a.k.a., decisions) as virtual documents
Rego lets you refer to both base and virtual documents through a global variable called data. Similarly, OPA lets you query for both base and virtual documents via the /v1/data HTTP API
Since base documents come from outside of OPA, their location under data is controlled by the software doing the loading. On the other hand, the location of virtual documents under data is controlled by policies themselves using the package directive in the language.
Base documents loaded asynchronously are always accessed under the data global variable. On the other hand, base documents can also be pushed or pulled into OPA synchronously when your software queries OPA for policy decisions. We refer to base documents pushed synchronously as "input".
if a package is a.b then it can be reached via the OPA http api at v1/data/a/b
OPA can use bundles which are tar.gz files of the policies. These can be loaded at startup or can be pushed (or pulled) to the OPA server from remote servers.
A bundle server is used to separate the policy evaluation from the policy and static data authoring. The server must implement a specific endpoint which returns the bundle.tar.gz file. OPA then polls the bundle server to get the bundles and any updates:
These seem to be the best way to capture policies and data for our needs. The bundles are loaded at OPA server startup - therefore any change to policies or static data will require a new deployment.
opa build -b policies will create bundle.tar.gz containing:
-rw------- 0/0 463 1970-01-01 01:00 /data.json
-rw------- 0/0 518 1970-01-01 01:00 /policies/api-example.rego
-rw------- 0/0 1349 1970-01-01 01:00 /policies/bcp-auth-policy.rego
-rw------- 0/0 1055 1970-01-01 01:00 /policies/example.rego
-rw------- 0/0 917 1970-01-01 01:00 /policies/rbac.rego
-rw------- 0/0 46 1970-01-01 01:00 /.manifest
Then we run opa with opa eval -b bundle.tar.gz 'data.rbac.allow with input as {"user": "eve", "action": "read", "type": "cat"}' or opa run -b bundle.tar.gz -w -s
Now we can run queries from Postman :
Consistency and Accuracy: The swagger-jsdoc approach tightly couples documentation to the code, making it far more reliable than manually maintained documentation.
Centralized Policy Management: OPA allows for a single, centralized policy repository, which is essential for enforcing consistent security rules across a large number of microservices.
Flexibility and Expressiveness: OPA's Rego language is highly expressive and can handle complex, attribute-based access control (ABAC) rules that would be difficult to implement and maintain in traditional imperative code.
Reduced Development Overhead: Developers don't need to write repetitive authorization checks in every route handler. They can offload that responsibility to OPA.
Let's illustrate this with a simple Express.js API that uses swagger-jsdoc for documentation and OPA for a user management endpoint.
api.js (Express.js server):
const express = require('express');
const app = express();
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const axios = require('axios'); // For communicating with OPA
const port = 3000;
app.use(express.json());
// Swagger JSDoc options
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'User Management API',
version: '1.0.0',
description: 'A simple API to manage user data with authorization via OPA.',
},
servers: [{ url: `http://localhost:${port}` }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
security: [{ bearerAuth: [] }],
},
apis: ['./api.js'], // Point to the file with JSDoc comments
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Middleware to check authorization with OPA
const authorize = async (req, res, next) => {
const opaUrl = 'http://localhost:8181/v1/data/httpapi/authz';
// Construct the OPA input payload
const input = {
method: req.method,
path: req.path.split('/').filter(p => p),
user: req.headers['x-user-id'], // Example user identifier
};
try {
const { data } = await axios.post(opaUrl, { input });
if (data.result.allow) {
next(); // User is authorized, proceed
} else {
res.status(403).json({ error: 'Forbidden' });
}
} catch (error) {
console.error('OPA communication error:', error.message);
res.status(500).json({ error: 'Internal Server Error' });
}
};
// --- API Endpoints ---
/**
* @swagger
* /users:
* get:
* summary: Retrieve a list of users.
* description: This endpoint requires authorization.
* responses:
* 200:
* description: A list of users.
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* name:
* type: string
* 403:
* description: Forbidden, user is not authorized.
*/
app.get('/users', authorize, (req, res) => {
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
res.json(users);
});
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
console.log(`API documentation available at http://localhost:${port}/api-docs`);
});
policy.rego (OPA policy):
package httpapi.authz
// By default, all requests are denied.
default allow = false
// Allow access to the '/users' endpoint if the user ID is 'admin'.
allow if {
input.method == "GET"
input.path == ["users"]
input.user == "admin"
}
Running the Example:
Start OPA: Run OPA in server mode with the policy file. opa run --server --set decision_logs.console=true policy.rego
Start the Express.js Server: node api.js
Test:
Navigate to http://localhost:3000/api-docs to see the generated Swagger UI.
Make a GET request to http://localhost:3000/users with a x-user-id header of admin. The request will be allowed.
Make the same request with any other user ID. The request will be denied with a 403 Forbidden error, and the OPA logs will show the decision.