Update API documentation and UI to improve clarity and add role-based access control.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/994ee92d-6a6d-4ca4-99c0-fe0dbc7f160b.jpg
This commit is contained in:
alphaeusmote
2025-04-08 02:23:25 +00:00
parent 58f8360240
commit a872feecd2
17 changed files with 1666 additions and 106 deletions
+18 -6
View File
@@ -110,12 +110,19 @@ export function setupAuth(app: Express) {
}
const hashedPassword = await hashPassword(password);
// Get the default role if role ID isn't specified
let roleId = req.body.roleId;
if (!roleId) {
const defaultRole = await storage.getDefaultRole();
roleId = defaultRole?.id;
}
const user = await storage.createUser({
username,
password: hashedPassword,
email: req.body.email,
fullName: req.body.fullName,
role: req.body.role || "user",
roleId: roleId,
});
// Remove password from response
@@ -132,7 +139,7 @@ export function setupAuth(app: Express) {
// Login endpoint
app.post("/api/login", (req, res, next) => {
passport.authenticate("local", (err, user, info) => {
passport.authenticate("local", (err: any, user: any, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(401).json({ message: info?.message || "Authentication failed" });
@@ -175,25 +182,30 @@ export function setupAuth(app: Express) {
}
try {
const { name, expiresAt, permissions } = req.body;
const { name, expiresAt, roleId, customPermissions } = req.body;
if (!name) {
return res.status(400).json({ message: "Token name is required" });
}
// Create JWT token with user ID and optional permissions
const token = jwt.sign(
{
sub: req.user.id,
permissions
customPermissions
},
JWT_SECRET,
{ expiresAt: expiresAt ? new Date(expiresAt) : undefined }
{
expiresIn: expiresAt ? Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000) : '365d'
}
);
// Store the token in the database
const apiToken = storage.createApiToken({
name,
token,
userId: req.user.id,
permissions: permissions || {},
roleId: roleId || null,
customPermissions: customPermissions || null,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
+194
View File
@@ -0,0 +1,194 @@
import { Request, Response, NextFunction } from "express";
import { db } from "./db";
import { roles, rolePermissions, users, apiTokens } from "@shared/schema";
import { eq, or, and, inArray } from "drizzle-orm";
// Types for role-based access control
export type RequireAuthOptions = {
allowApiToken?: boolean;
};
export type RequirePermissionOptions = RequireAuthOptions & {
anyOf?: string[];
allOf?: string[];
};
// Default middleware that verifies a user is authenticated
export function requireAuth(options: RequireAuthOptions = {}) {
return async (req: Request, res: Response, next: NextFunction) => {
// Check for session authentication
if (req.isAuthenticated()) {
return next();
}
// Check for API token authentication if allowed
if (options.allowApiToken) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
try {
// Lookup the token
const [apiToken] = await db.select()
.from(apiTokens)
.where(eq(apiTokens.token, token))
.limit(1);
if (!apiToken) {
return res.status(401).json({ message: "Invalid API token" });
}
// Check if token has expired
if (apiToken.expiresAt && new Date(apiToken.expiresAt) < new Date()) {
return res.status(401).json({ message: "API token has expired" });
}
// Set token info on the request
req.user = {
id: apiToken.userId,
username: '',
password: '',
fullName: null,
email: null,
createdAt: null,
// Add tokenId for identifying which token was used
tokenId: apiToken.id,
// Add roleId for permission checking
roleId: apiToken.roleId,
// Store custom permissions if available
customPermissions: (Array.isArray(apiToken.customPermissions) ?
apiToken.customPermissions :
(apiToken.customPermissions ? JSON.parse(String(apiToken.customPermissions)) : [])) as string[]
};
return next();
} catch (error) {
console.error("API token authentication error:", error);
return res.status(500).json({ message: "Internal server error" });
}
}
}
// Not authenticated through any method
return res.status(401).json({ message: "Not authenticated" });
};
}
// Middleware for checking specific permissions
export function requirePermission(permission: string, options: RequirePermissionOptions = {}) {
return async (req: Request, res: Response, next: NextFunction) => {
// First ensure the user is authenticated
const authMiddleware = requireAuth(options);
authMiddleware(req, res, async () => {
try {
if (!req.user) {
return res.status(401).json({ message: "Not authenticated" });
}
// Get the permissions for the user's role
let hasPermission = false;
// Check for API token custom permissions first if present
if (req.user?.tokenId && Array.isArray(req.user?.customPermissions)) {
// For API tokens with custom permissions
const customPermissions = req.user.customPermissions;
if (customPermissions.includes(permission) ||
options.anyOf?.some(p => customPermissions.includes(p))) {
hasPermission = true;
}
}
// If no permission yet, check role-based permissions
if (!hasPermission && req.user.roleId) {
// Get permissions assigned to the role
const rolePerms = await db.select()
.from(rolePermissions)
.where(eq(rolePermissions.roleId, req.user.roleId));
const userPermissions = rolePerms.map(rp => rp.permission);
// Check for the specific permission or any of the optional permissions
if (userPermissions.includes(permission) ||
options.anyOf?.some(p => userPermissions.includes(p))) {
hasPermission = true;
}
// If allOf is specified, ensure all required permissions are present
if (options.allOf && options.allOf.length > 0) {
hasPermission = options.allOf.every(p => userPermissions.includes(p));
}
}
// Check if user has the admin:system permission which grants all access
if (!hasPermission && req.user.roleId) {
const adminPerm = await db.select()
.from(rolePermissions)
.where(and(
eq(rolePermissions.roleId, req.user.roleId),
eq(rolePermissions.permission, "admin:system")
))
.limit(1);
if (adminPerm.length > 0) {
hasPermission = true;
}
}
if (hasPermission) {
return next();
}
return res.status(403).json({ message: "Insufficient permissions" });
} catch (error) {
console.error("Permission check error:", error);
return res.status(500).json({ message: "Internal server error" });
}
});
};
}
// Convenience middleware for requiring administrative access
export function requireAdmin() {
return requirePermission("admin:system");
}
// Get permissions for a user
export async function getUserPermissions(userId: number): Promise<string[]> {
const [user] = await db.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || !user.roleId) {
return [];
}
const rolePerms = await db.select()
.from(rolePermissions)
.where(eq(rolePermissions.roleId, user.roleId));
return rolePerms.map(rp => rp.permission);
}
// Initialize on server startup to ensure default roles
export async function initializeRBAC() {
try {
// Check for existing data before doing anything
const [adminRole] = await db.select()
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1);
if (adminRole) {
console.log("RBAC already initialized");
return true;
}
console.log("RBAC initialization skipped - should be handled by updateSchema.ts");
return true;
} catch (error) {
console.error("Failed to initialize RBAC:", error);
return false;
}
}
+21 -16
View File
@@ -3,8 +3,14 @@ import { createServer, type Server } from "http";
import { setupAuth } from "./auth";
import { setupSwagger } from "./swagger";
import { storage } from "./storage";
import { apiQuerySchema } from "@shared/schema";
import { apiQuerySchema, PERMISSIONS } from "@shared/schema";
import { ZodError } from "zod";
import {
requireAuth,
requirePermission,
requireAdmin,
initializeRBAC
} from "./authorization";
export async function registerRoutes(app: Express): Promise<Server> {
// Setup authentication
@@ -13,6 +19,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Setup Swagger documentation
setupSwagger(app);
// Initialize Role Based Access Control system
await initializeRBAC();
// Error handler for Zod validation errors
const handleZodError = (err: ZodError, res: Response) => {
return res.status(400).json({
@@ -33,14 +42,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
};
// Middleware to check admin role
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (!req.isAuthenticated() || req.user.role !== "admin") {
return res.status(403).json({ message: "Access denied: Admin role required" });
}
next();
};
/**
* @swagger
* /api/ldap-connections:
@@ -49,6 +50,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
* tags: [LDAP Connections]
* security:
* - cookieAuth: []
* - bearerAuth: []
* responses:
* 200:
* description: A list of LDAP connections
@@ -60,13 +62,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
* $ref: '#/components/schemas/LdapConnection'
* 401:
* $ref: '#/components/responses/UnauthorizedError'
* 403:
* $ref: '#/components/responses/ForbiddenError'
*/
app.get("/api/ldap-connections", async (req, res, next) => {
app.get("/api/ldap-connections", requirePermission(PERMISSIONS.VIEW_LDAP_CONNECTIONS, { allowApiToken: true }), async (req, res, next) => {
try {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: "Not authenticated" });
}
const connections = await storage.listLdapConnections();
// Hide sensitive fields like password
@@ -360,8 +360,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
// Only allow users to delete their own tokens unless they're admin
if (token.userId !== req.user.id && req.user.role !== "admin") {
return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" });
if (token.userId !== req.user.id) {
// Get the user's role
const userRole = await storage.getRole(req.user.roleId!);
if (userRole?.name !== "admin") {
return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" });
}
}
const deleted = await storage.deleteApiToken(parseInt(req.params.id));
+24 -8
View File
@@ -3,8 +3,9 @@ import {
LdapConnection, InsertLdapConnection,
AdUser, InsertAdUser, AdGroup, InsertAdGroup,
AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer,
AdDomain, InsertAdDomain,
users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains
AdDomain, InsertAdDomain, Role,
users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains,
roles
} from "@shared/schema";
import session from "express-session";
import createMemoryStore from "memorystore";
@@ -32,7 +33,11 @@ export interface IStorage {
updateUser(id: number, user: Partial<User>): Promise<User | undefined>;
deleteUser(id: number): Promise<boolean>;
listUsers(): Promise<User[]>;
// Role management
getRole(id: number): Promise<Role | undefined>;
getDefaultRole(): Promise<Role | undefined>;
// API Token management
getApiToken(id: number): Promise<ApiToken | undefined>;
getApiTokenByToken(token: string): Promise<ApiToken | undefined>;
@@ -126,6 +131,17 @@ export class DatabaseStorage implements IStorage {
async listUsers(): Promise<User[]> {
return db.select().from(users);
}
// Role management
async getRole(id: number): Promise<Role | undefined> {
const result = await db.select().from(roles).where(eq(roles.id, id));
return result.length > 0 ? result[0] : undefined;
}
async getDefaultRole(): Promise<Role | undefined> {
const result = await db.select().from(roles).where(eq(roles.isDefault, true));
return result.length > 0 ? result[0] : undefined;
}
// API Token management
async getApiToken(id: number): Promise<ApiToken | undefined> {
@@ -210,7 +226,7 @@ export class DatabaseStorage implements IStorage {
return users.map(user => {
const result: any = { id: user.id };
properties.forEach(prop => {
properties.forEach((prop: string) => {
if ((user as any)[prop] !== undefined) {
result[prop] = (user as any)[prop];
}
@@ -253,7 +269,7 @@ export class DatabaseStorage implements IStorage {
return groups.map(group => {
const result: any = { id: group.id };
properties.forEach(prop => {
properties.forEach((prop: string) => {
if ((group as any)[prop] !== undefined) {
result[prop] = (group as any)[prop];
}
@@ -296,7 +312,7 @@ export class DatabaseStorage implements IStorage {
return orgUnits.map(ou => {
const result: any = { id: ou.id };
properties.forEach(prop => {
properties.forEach((prop: string) => {
if ((ou as any)[prop] !== undefined) {
result[prop] = (ou as any)[prop];
}
@@ -339,7 +355,7 @@ export class DatabaseStorage implements IStorage {
return computers.map(computer => {
const result: any = { id: computer.id };
properties.forEach(prop => {
properties.forEach((prop: string) => {
if ((computer as any)[prop] !== undefined) {
result[prop] = (computer as any)[prop];
}
@@ -382,7 +398,7 @@ export class DatabaseStorage implements IStorage {
return domains.map(domain => {
const result: any = { id: domain.id };
properties.forEach(prop => {
properties.forEach((prop: string) => {
if ((domain as any)[prop] !== undefined) {
result[prop] = (domain as any)[prop];
}
+10 -1
View File
@@ -53,7 +53,7 @@ const swaggerOptions = {
name: { type: "string" },
token: { type: "string" },
userId: { type: "integer" },
permissions: { type: "object" },
customPermissions: { type: "array", items: { type: "string" } },
expiresAt: { type: "string", format: "date-time" },
createdAt: { type: "string", format: "date-time" },
},
@@ -240,9 +240,18 @@ const swaggerOptions = {
const swaggerSpec = swaggerJsdoc(swaggerOptions);
export function setupSwagger(app: Express) {
// Mount at both paths for backward compatibility
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Provide the JSON spec at multiple paths
app.get("/api/swagger.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.send(swaggerSpec);
});
app.get("/api-docs/swagger.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.send(swaggerSpec);
});
}