Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .storybook/20260625_rbac_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-- SQL Schema for SubTrackr RBAC System
-- Migration Timestamp: 20260625

--
-- roles: Defines the available roles in the system.
--
CREATE TABLE IF NOT EXISTS roles (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
is_custom BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

--
-- role_permissions: Maps permissions to roles.
--
CREATE TABLE IF NOT EXISTS role_permissions (
role_id VARCHAR(50) NOT NULL,
permission VARCHAR(100) NOT NULL,
PRIMARY KEY (role_id, permission),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

--
-- user_roles: Assigns a single role to each user.
--
CREATE TABLE IF NOT EXISTS user_roles (
user_id VARCHAR(255) PRIMARY KEY,
role_id VARCHAR(50) NOT NULL DEFAULT 'viewer',
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE SET DEFAULT
);

--
-- permission_audit_logs: Records every permission check for auditing.
--
CREATE TABLE IF NOT EXISTS permission_audit_logs (
id SERIAL PRIMARY KEY,
actor_id VARCHAR(255) NOT NULL,
resource VARCHAR(100) NOT NULL,
action VARCHAR(100) NOT NULL,
outcome VARCHAR(10) NOT NULL, -- 'ALLOW' or 'DENY'
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

--
-- Seed Data for Predefined Roles
--

-- 1. Roles
INSERT INTO roles (id, name, description, is_custom) VALUES
('admin', 'Administrator', 'Full access to all resources and operations.', FALSE),
('billing', 'Billing Manager', 'Manages billing and invoices.', FALSE),
('support', 'Support Agent', 'Read-only access to subscriptions and invoices for support tasks.', FALSE),
('viewer', 'Viewer', 'Read-only access to all resources.', FALSE)
ON CONFLICT (id) DO NOTHING;

-- 2. Permissions for Predefined Roles
-- Admin: all:*
INSERT INTO role_permissions (role_id, permission) VALUES
('admin', 'all:*')
ON CONFLICT DO NOTHING;

-- Billing: billing:*, invoice:*
INSERT INTO role_permissions (role_id, permission) VALUES
('billing', 'billing:*'),
('billing', 'invoice:*')
ON CONFLICT DO NOTHING;

-- Support: subscription:read, invoice:read
INSERT INTO role_permissions (role_id, permission) VALUES
('support', 'subscription:read'),
('support', 'invoice:read')
ON CONFLICT DO NOTHING;

-- Viewer: *:read
INSERT INTO role_permissions (role_id, permission) VALUES
('viewer', '*:read')
ON CONFLICT DO NOTHING;
40 changes: 40 additions & 0 deletions .storybook/PermissionRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* PermissionRegistry provides static methods for checking fine-grained,
* resource-level permissions with wildcard support.
*/
class PermissionRegistry {
/**
* Checks if a set of user permissions grants access for a required permission.
* Supports 'resource:action' format with wildcards.
*
* Wildcard Rules:
* - 'all:*': Grants access to everything.
* - 'resource:*': Grants access to all actions for a specific resource.
* - '*:action': Grants access to a specific action on any resource.
*
* @param {string[]} userPermissions - An array of permissions assigned to the user (e.g., ['subscription:read', 'billing:*']).
* @param {string} requiredPermission - The permission required for the action (e.g., 'subscription:cancel').
* @returns {boolean} - True if access is granted, otherwise false.
*/
static hasPermission(userPermissions, requiredPermission) {
if (!userPermissions || userPermissions.length === 0) {
return false;
}

const [reqResource, reqAction] = requiredPermission.split(':');

for (const perm of userPermissions) {
if (perm === 'all:*') return true;
if (perm === requiredPermission) return true;

const [permResource, permAction] = perm.split(':');

if (permResource === reqResource && permAction === '*') return true;
if (permResource === '*' && permAction === reqAction) return true;
}

return false;
}
}

module.exports = PermissionRegistry;
9 changes: 3 additions & 6 deletions .storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
/**
* Storybook Configuration for SubTrackr Design System
*
*
* Location: .storybook/main.js
* Run: npm run storybook
*/

module.exports = {
stories: [
'../src/design-system/stories/**/*.stories.{ts,tsx}',
'../src/**/*.stories.{ts,tsx}',
],
stories: ['../src/design-system/stories/**/*.stories.{ts,tsx}', '../src/**/*.stories.{ts,tsx}'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-ondevice-actions',
Expand All @@ -30,7 +27,7 @@ module.exports = {
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesAsTypes: true,
shouldRemoveUndefinedFromOptional: true,
propFilter: (prop: any) => {
propFilter: (prop) => {
if (prop.parent) {
return !prop.parent.fileName.includes('node_modules');
}
Expand Down
2 changes: 1 addition & 1 deletion .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Storybook Preview Configuration
*
*
* Location: .storybook/preview.js
*/

Expand Down
85 changes: 85 additions & 0 deletions .storybook/rbacMiddleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const PermissionRegistry = require('../rbac/PermissionRegistry');
// Assume you have a configured database client
// const dbClient = require('../../db');

/**
* Mock database client for demonstration.
* Replace with your actual database query implementation.
*/
const dbClient = {
query: async (sql, params) => {
console.log('Executing SQL:', sql, params);
// In a real app, this would query your database.
// This mock is for structure and demonstration purposes.
if (sql.includes('user_roles')) {
return { rows: [{ role_id: 'admin' }] }; // Mock: user is always admin
}
if (sql.includes('role_permissions')) {
return { rows: [{ permission: 'all:*' }] }; // Mock: admin has all permissions
}
return { rows: [] };
},
};

/**
* Express middleware factory to enforce RBAC permissions.
*
* @param {string} requiredPermission - The permission string required for the endpoint (e.g., 'subscription:create').
* @returns {function} An Express middleware function.
*/
function requirePermission(requiredPermission) {
const [resource, action] = requiredPermission.split(':');

return async (req, res, next) => {
// Assume user ID is attached to the request object by a prior auth middleware.
const actorId = req.user?.id;

if (!actorId) {
return res.status(401).json({ message: 'Authentication required.' });
}

let outcome = 'DENY';
try {
// 1. Get user's role from the database.
const userRoleResult = await dbClient.query(
'SELECT role_id FROM user_roles WHERE user_id = $1',
[actorId]
);

if (userRoleResult.rows.length === 0) {
throw new Error('User has no assigned role.');
}
const { role_id: roleId } = userRoleResult.rows[0];

// 2. Get all permissions for that role.
const rolePermsResult = await dbClient.query(
'SELECT permission FROM role_permissions WHERE role_id = $1',
[roleId]
);
const userPermissions = rolePermsResult.rows.map((r) => r.permission);

// 3. Check for permission.
const hasAccess = PermissionRegistry.hasPermission(userPermissions, requiredPermission);

if (hasAccess) {
outcome = 'ALLOW';
return next();
}

return res
.status(403)
.json({ message: 'Forbidden: You do not have permission to perform this action.' });
} catch (error) {
console.error('RBAC middleware error:', error);
return res.status(500).json({ message: 'Internal server error during permission check.' });
} finally {
// 4. CRITICAL: Record the audit log for every check.
await dbClient.query(
'INSERT INTO permission_audit_logs (actor_id, resource, action, outcome) VALUES ($1, $2, $3, $4)',
[actorId, resource, action, outcome]
);
}
};
}

module.exports = requirePermission;
116 changes: 116 additions & 0 deletions .storybook/rbacMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Request, Response, NextFunction } from 'express';
import { PermissionRegistry } from '../rbac/PermissionRegistry';
import { dbClient } from '../../db'; // Assume a real, configured DB client
import { logger } from '../../services/logger'; // Assume a structured logger

/**
* Represents the user object attached to the request by a preceding
* authentication middleware. It should include pre-fetched permissions.
*/
interface AuthenticatedUser {
id: string;
permissions: string[];
}

/**
* Extends the Express Request type to include our authenticated user.
*/
interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}

/**
* Express middleware factory to enforce RBAC permissions.
*
* This improved version assumes that user permissions are fetched once upon
* login and attached to the `req.user` object, avoiding database lookups
* on every single API call for better performance.
*
* @param {string} requiredPermission - The permission string required for the endpoint (e.g., 'subscription:create').
* @returns {function} An Express middleware function for use in routes.
*/
export function requirePermission(requiredPermission: string) {
const [resource, action] = requiredPermission.split(':', 2);

return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
const actorId = req.user?.id;
const userPermissions = req.user?.permissions ?? [];
const requestId = req.headers['x-request-id'] || 'unknown';

if (!actorId) {
// This case should ideally be handled by a preceding auth middleware,
// but we check again as a safeguard.
res.status(401).json({ message: 'Authentication required.' });
return;
}

// Abort handling: If the client disconnects, we still must log the final outcome.
req.on('close', () => {
if (res.writableEnded) return;
// Fire-and-forget audit log on client abort.
audit(actorId, resource, action, outcome);
});

let outcome = 'DENY';

try {
// Check permissions using the cached list from the user object.
const hasAccess = PermissionRegistry.hasPermission(userPermissions, requiredPermission);

if (hasAccess) {
outcome = 'ALLOW';
next();
return;
}

// If access is denied, log it and send a 403 Forbidden response.
// The structured log now includes the request ID for better correlation.
logger.warn({
message: 'Permission denied',
actorId,
required: requiredPermission,
permissions: userPermissions,
requestId,
});

if (res.headersSent) return;
res
.status(403)
.json({ message: 'Forbidden: You do not have permission to perform this action.' });
} catch (error) {
logger.error({
message: 'RBAC middleware encountered an unexpected error.',
error,
actorId,
required: requiredPermission,
requestId,
});
if (res.headersSent) return;
res.status(500).json({ message: 'Internal server error during permission check.' });
} finally {
// CRITICAL: Record the audit log for every check, regardless of outcome.
// We only write here if the response is still open. The 'close' handler covers aborts.
if (!res.writableEnded) {
audit(actorId, resource, action, outcome);
}
}
};
}

/**
* Fire-and-forget audit log function. We do not want audit failures to
* block the main request flow, so we log errors to our monitoring service.
*/
function audit(actorId: string, resource: string, action: string, outcome: 'ALLOW' | 'DENY'): void {
dbClient
.query(
'INSERT INTO permission_audit_logs (actor_id, resource, action, outcome) VALUES ($1, $2, $3, $4)',
[actorId, resource, action, outcome]
)
.catch((auditError) => {
logger.error({
message: 'Failed to write to permission_audit_logs',
error: auditError,
});
});
}
Loading