diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 00000000..9ba0e0ce --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,7 @@ +--- +exclude_paths: + - "docs/**" + - "coverage/**" + - "dist/**" + - ".yarn/**" + - "node_modules/**" diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 20daccbc..a6bd9957 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -1,7 +1,10 @@ # .markdownlint.yaml # Tell markdownlint to allow 2 spaces after a list marker, matching Prettier's style. MD030: - ul_single: 2 - ol_single: 2 - ul_multi: 2 - ol_multi: 2 + ul_single: 2 + ol_single: 2 + ul_multi: 2 + ol_multi: 2 + +# Disable line length rule for markdown files +MD013: false diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 00000000..097760b5 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,6 @@ +# Documentation build artifacts and generated pages +docs/** +coverage/** +dist/** +node_modules/** +.yarn/** diff --git a/packages/auth-rbac/src/engine/RbacPolicyEngine.ts b/packages/auth-rbac/src/engine/RbacPolicyEngine.ts index 883e1f03..7d1df11f 100644 --- a/packages/auth-rbac/src/engine/RbacPolicyEngine.ts +++ b/packages/auth-rbac/src/engine/RbacPolicyEngine.ts @@ -11,10 +11,15 @@ import type { import { mapHttpMethodToAction } from '../types' import { TarpitManager } from './TarpitManager' +/** + * Accepted semantic action, HTTP verb or custom action string for route evaluation. + */ +export type RouteActionInput = RbacAction | HttpMethod | (string & Record) + /** * Normalizes an action or HTTP method to unified semantic RbacAction. */ -function normalizeAction(actionOrMethod: string): { semantic: RbacAction; raw: string } { +function normalizeAction(actionOrMethod: RouteActionInput): { semantic: RbacAction; raw: string } { const upper = (actionOrMethod || 'READ').toUpperCase() if (['READ', 'WRITE', 'CREATE', 'UPDATE', 'DELETE', 'EXECUTE', 'MANAGE', '*'].includes(upper)) { return { semantic: upper as RbacAction, raw: upper } @@ -50,7 +55,7 @@ function matchGlob(pattern: string, uri: string): boolean { .map((segment) => segment .split('*') - .map((sub) => sub.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&')) + .map((sub) => sub.replace(/[-[\]{}()+?.,\\^$|#\s]/g, String.raw`\$&`)) .join('[^/]+') ) .join('.*') + @@ -65,7 +70,7 @@ function matchGlob(pattern: string, uri: string): boolean { * Manages role hierarchies, route authorizations, payload sanitization, and M2M tarpitting. */ export class RbacPolicyEngine { - private roles: Map = new Map() + private readonly roles = new Map() /** * Dedicated manager handling rate-limiting, anomaly detection, and intentional tarpitting latency. */ @@ -128,83 +133,113 @@ export class RbacPolicyEngine { } /** - * Evaluates route access for a user context against a target URI and semantic action (or HTTP method). - * - * @param user - Authenticated user context. - * @param uri - Requested URI path. - * @param actionOrMethod - Semantic action ('READ', 'WRITE', 'UPDATE', 'DELETE') or HTTP method ("GET", "POST"...). - * @returns Detailed evaluation result including allow/deny decision and tarpit latency. + * Evaluates tarpit delays and potential block state across applicable roles. */ - public evaluateRoute( + private evaluateTarpitState( user: RbacUserContext, - uri: string, - actionOrMethod: RbacAction | HttpMethod | string = 'READ' - ): RouteEvaluationResult { - const normUri = normalizeUri(uri) - const normalized = normalizeAction(actionOrMethod) - const applicableRoles = this.getApplicableRoles(user) - - // 1. Tarpit Evaluation for M2M Agents and suspicious traffic - let highestTarpitDelay = 0 + roles: RoleDefinition[] + ): { tarpitDelayMs: number; isThrottled: boolean; isBlocked: boolean } { + let tarpitDelayMs = 0 let isThrottled = false - for (const role of applicableRoles) { + for (const role of roles) { if (role.tarpit && role.tarpit.enabled !== false) { const subjectKey = `${user.subjectType || 'human'}:${user.id}` const tarpitRes = this.tarpitManager.evaluate(subjectKey, role.tarpit) - if (tarpitRes.delayMs > highestTarpitDelay) { - highestTarpitDelay = tarpitRes.delayMs + if (tarpitRes.delayMs > tarpitDelayMs) { + tarpitDelayMs = tarpitRes.delayMs } if (tarpitRes.isThrottled) { isThrottled = true } if (tarpitRes.isBlocked) { - return { - allowed: false, - decision: 'deny', - tarpitDelayMs: highestTarpitDelay, - isThrottled: true, - reason: 'Subject is temporarily blocked due to repeated rate limit violations (Tarpit Lock).' - } + return { tarpitDelayMs, isThrottled: true, isBlocked: true } } } } - // 2. Gather all route rules from applicable roles + return { tarpitDelayMs, isThrottled, isBlocked: false } + } + + /** + * Determines if a route rule matches the given semantic or HTTP action. + */ + private isRuleActionMatch( + rule: RouteRule, + normalized: { semantic: RbacAction; raw: string } + ): boolean { + const declaredActions = rule.actions || rule.methods || [] + if (declaredActions.length === 0) return true + if (declaredActions.includes('*') || declaredActions.includes('MANAGE')) return true + if (declaredActions.includes(normalized.semantic)) return true + if (declaredActions.includes(normalized.raw as any)) return true + return normalized.semantic === 'WRITE' && declaredActions.includes('CREATE' as any) + } + + /** + * Scans applicable roles for the highest specificity matching route rule. + */ + private findMatchingRouteRule( + roles: RoleDefinition[], + normUri: string, + normalized: { semantic: RbacAction; raw: string } + ): RouteRule | null { const matchingRules: { rule: RouteRule; score: number }[] = [] - for (const role of applicableRoles) { + for (const role of roles) { if (!role.routes) continue for (const rule of role.routes) { - const declaredActions = rule.actions || rule.methods || [] - const actionMatches = - declaredActions.length === 0 || - declaredActions.includes('*') || - declaredActions.includes('MANAGE') || - declaredActions.includes(normalized.semantic) || - declaredActions.includes(normalized.raw as any) || - (normalized.semantic === 'WRITE' && declaredActions.includes('CREATE' as any)) - - if (actionMatches && matchGlob(rule.pattern, normUri)) { - // Specificity score: longer patterns have higher priority - const score = rule.pattern.replace(/\*/g, '').length + if (this.isRuleActionMatch(rule, normalized) && matchGlob(rule.pattern, normUri)) { + const score = rule.pattern.replaceAll('*', '').length matchingRules.push({ rule, score }) } } } - // Sort by specificity descending + if (matchingRules.length === 0) return null matchingRules.sort((a, b) => b.score - a.score) + return matchingRules[0].rule + } + + /** + * Evaluates route access for a user context against a target URI and semantic action (or HTTP method). + * + * @param user - Authenticated user context. + * @param uri - Requested URI path. + * @param actionOrMethod - Semantic action ('READ', 'WRITE', 'UPDATE', 'DELETE') or HTTP method ("GET", "POST"...). + * @returns Detailed evaluation result including allow/deny decision and tarpit latency. + */ + public evaluateRoute( + user: RbacUserContext, + uri: string, + actionOrMethod: RouteActionInput = 'READ' + ): RouteEvaluationResult { + const normUri = normalizeUri(uri) + const normalized = normalizeAction(actionOrMethod) + const applicableRoles = this.getApplicableRoles(user) - if (matchingRules.length > 0) { - const topMatch = matchingRules[0].rule + // 1. Tarpit Evaluation for M2M Agents and suspicious traffic + const tarpit = this.evaluateTarpitState(user, applicableRoles) + if (tarpit.isBlocked) { + return { + allowed: false, + decision: 'deny', + tarpitDelayMs: tarpit.tarpitDelayMs, + isThrottled: true, + reason: 'Subject is temporarily blocked due to repeated rate limit violations (Tarpit Lock).' + } + } + + // 2. Gather all route rules from applicable roles + const matchedRule = this.findMatchingRouteRule(applicableRoles, normUri, normalized) + if (matchedRule) { return { - allowed: topMatch.access === 'allow', - decision: topMatch.access, - matchedRule: topMatch, - tarpitDelayMs: highestTarpitDelay, - isThrottled + allowed: matchedRule.access === 'allow', + decision: matchedRule.access, + matchedRule, + tarpitDelayMs: tarpit.tarpitDelayMs, + isThrottled: tarpit.isThrottled } } @@ -212,8 +247,8 @@ export class RbacPolicyEngine { return { allowed: false, decision: 'deny', - tarpitDelayMs: highestTarpitDelay, - isThrottled, + tarpitDelayMs: tarpit.tarpitDelayMs, + isThrottled: tarpit.isThrottled, reason: 'No matching route rule found (Default Deny).' } } @@ -224,7 +259,7 @@ export class RbacPolicyEngine { public canAccessRoute( user: RbacUserContext, uri: string, - actionOrMethod: RbacAction | HttpMethod | string = 'READ' + actionOrMethod: RouteActionInput = 'READ' ): boolean { return this.evaluateRoute(user, uri, actionOrMethod).allowed } diff --git a/packages/auth-rbac/src/engine/TarpitManager.ts b/packages/auth-rbac/src/engine/TarpitManager.ts index b9cc2111..6d564c3c 100644 --- a/packages/auth-rbac/src/engine/TarpitManager.ts +++ b/packages/auth-rbac/src/engine/TarpitManager.ts @@ -11,7 +11,7 @@ interface SubjectTrafficRecord { * Designed to neutralize aggressive scraping, brute-force attempts, and runaway AI agent loops. */ export class TarpitManager { - private traffic: Map = new Map() + private readonly traffic: Map = new Map() /** * Evaluates request traffic for a given subject key and returns the required tarpit delay or blocking decision. diff --git a/packages/auth-rbac/src/index.ts b/packages/auth-rbac/src/index.ts index a2deada1..cda71edf 100644 --- a/packages/auth-rbac/src/index.ts +++ b/packages/auth-rbac/src/index.ts @@ -1,10 +1,11 @@ export * from './types' -export { RbacPolicyEngine } from './engine/RbacPolicyEngine' +export { RbacPolicyEngine, type RouteActionInput } from './engine/RbacPolicyEngine' export { TarpitManager } from './engine/TarpitManager' export { AbstractRbacMiddleware } from './middlewares/AbstractRbacMiddleware' export { ExpressRbacMiddleware, type ExpressRbacOptions, + type ExpressUserResolver, type ExpressLikeRequest, type ExpressLikeResponse, type ExpressLikeNextFunction @@ -12,6 +13,7 @@ export { export { AstroRbacMiddleware, type AstroRbacOptions, + type AstroUserResolver, type AstroLikeContext, type AstroLikeMiddlewareNext } from './middlewares/AstroRbacMiddleware' diff --git a/packages/auth-rbac/src/middlewares/AbstractRbacMiddleware.ts b/packages/auth-rbac/src/middlewares/AbstractRbacMiddleware.ts index edca1604..405fc59f 100644 --- a/packages/auth-rbac/src/middlewares/AbstractRbacMiddleware.ts +++ b/packages/auth-rbac/src/middlewares/AbstractRbacMiddleware.ts @@ -35,7 +35,7 @@ export abstract class AbstractRbacMiddleware | any + ): Promise | unknown /** * Factory method building a scoped `RbacRequestContext` helper for controllers and templates. diff --git a/packages/auth-rbac/src/middlewares/AstroRbacMiddleware.ts b/packages/auth-rbac/src/middlewares/AstroRbacMiddleware.ts index 62c1bd81..437ea88c 100644 --- a/packages/auth-rbac/src/middlewares/AstroRbacMiddleware.ts +++ b/packages/auth-rbac/src/middlewares/AstroRbacMiddleware.ts @@ -13,12 +13,17 @@ export interface AstroLikeContext { export type AstroLikeMiddlewareNext = () => Promise +/** + * Resolver function signature retrieving user context from Astro context. + */ +export type AstroUserResolver = (context: AstroLikeContext) => Promise | RbacUserContext | null + /** * Options for configuring AstroRbacMiddleware. */ export interface AstroRbacOptions { /** Custom extractor function to retrieve the user context from Astro context */ - userResolver?: (context: AstroLikeContext) => Promise | RbacUserContext | null + userResolver?: AstroUserResolver /** URL path to redirect unauthenticated users for HTML pages (defaults to "/login") */ loginRedirectPath?: string /** URL path to redirect forbidden users for HTML pages (defaults to "/403") */ @@ -32,10 +37,10 @@ export interface AstroRbacOptions { * Unifies API endpoints (JSON responses) and SSR pages (redirects / forbidden status). */ export class AstroRbacMiddleware extends AbstractRbacMiddleware { - private userResolver?: (context: AstroLikeContext) => Promise | RbacUserContext | null - private loginRedirectPath: string - private forbiddenRedirectPath: string - private enableTarpitSleep: boolean + private readonly userResolver?: AstroUserResolver + private readonly loginRedirectPath: string + private readonly forbiddenRedirectPath: string + private readonly enableTarpitSleep: boolean constructor(engine: any, options: AstroRbacOptions = {}) { super(engine) @@ -143,13 +148,13 @@ export class AstroRbacMiddleware extends AbstractRbacMiddleware 0 - ? 'tarpit_blocked' - : user - ? 'forbidden' - : 'unauthenticated' + let reason: 'unauthenticated' | 'forbidden' | 'tarpit_blocked' = 'unauthenticated' + if (evaluation.isThrottled && evaluation.tarpitDelayMs > 0) { + reason = 'tarpit_blocked' + } else if (user) { + reason = 'forbidden' + } return this.handleAccessDenied(context, null, reason) } diff --git a/packages/auth-rbac/src/middlewares/ExpressRbacMiddleware.ts b/packages/auth-rbac/src/middlewares/ExpressRbacMiddleware.ts index 8b7a2508..3c4de08d 100644 --- a/packages/auth-rbac/src/middlewares/ExpressRbacMiddleware.ts +++ b/packages/auth-rbac/src/middlewares/ExpressRbacMiddleware.ts @@ -28,12 +28,17 @@ export interface ExpressLikeResponse { export type ExpressLikeNextFunction = (err?: any) => void +/** + * Resolver function signature retrieving user context from Express request. + */ +export type ExpressUserResolver = (req: ExpressLikeRequest) => Promise | RbacUserContext | null + /** * Options for configuring ExpressRbacMiddleware. */ export interface ExpressRbacOptions { /** Custom extractor function to retrieve the user context from request */ - userResolver?: (req: ExpressLikeRequest) => Promise | RbacUserContext | null + userResolver?: ExpressUserResolver /** Whether to inject tarpit delay asynchronously before calling next() */ enableTarpitSleep?: boolean } @@ -46,8 +51,8 @@ export class ExpressRbacMiddleware extends AbstractRbacMiddleware< ExpressLikeResponse, ExpressLikeNextFunction > { - private userResolver?: (req: ExpressLikeRequest) => Promise | RbacUserContext | null - private enableTarpitSleep: boolean + private readonly userResolver?: ExpressUserResolver + private readonly enableTarpitSleep: boolean constructor(engine: any, options: ExpressRbacOptions = {}) { super(engine) @@ -136,13 +141,13 @@ export class ExpressRbacMiddleware extends AbstractRbacMiddleware< res.setHeader('X-Security-Tarpit-Delay', `${evaluation.tarpitDelayMs}ms`) } - // 4. Access Decision if (!evaluation.allowed) { - const reason = evaluation.isThrottled && evaluation.tarpitDelayMs > 0 - ? 'tarpit_blocked' - : user - ? 'forbidden' - : 'unauthenticated' + let reason: 'unauthenticated' | 'forbidden' | 'tarpit_blocked' = 'unauthenticated' + if (evaluation.isThrottled && evaluation.tarpitDelayMs > 0) { + reason = 'tarpit_blocked' + } else if (user) { + reason = 'forbidden' + } return this.handleAccessDenied(req, res, reason) } diff --git a/packages/backend/src/MockAdapter.ts b/packages/backend/src/MockAdapter.ts index 0d0181f0..ce9d47a6 100644 --- a/packages/backend/src/MockAdapter.ts +++ b/packages/backend/src/MockAdapter.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import { Core, ObjectUri } from '@quatrain/core' import { DataObjectClass } from './types/DataObjectClass' import { BackendError } from './BackendError' @@ -82,9 +83,10 @@ export class MockAdapter */ protected generateId(length = 12): string { const chars = '0123456789abcdefghijklmnopqrstuvwxyz' + const bytes = crypto.randomBytes(length) let result = '' for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)) + result += chars.charAt(bytes[i] % chars.length) } return result }