Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
exclude_paths:
- "docs/**"
- "coverage/**"
- "dist/**"
- ".yarn/**"
- "node_modules/**"
11 changes: 7 additions & 4 deletions .markdownlint.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .markdownlintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Documentation build artifacts and generated pages
docs/**
coverage/**
dist/**
node_modules/**
.yarn/**
143 changes: 89 additions & 54 deletions packages/auth-rbac/src/engine/RbacPolicyEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>)

/**
* 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 }
Expand Down Expand Up @@ -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('.*') +
Expand All @@ -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<string, RoleDefinition> = new Map()
private readonly roles = new Map<string, RoleDefinition>()
/**
* Dedicated manager handling rate-limiting, anomaly detection, and intentional tarpitting latency.
*/
Expand Down Expand Up @@ -128,92 +133,122 @@ 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
}
}

// Default Deny if no explicit rule matched
return {
allowed: false,
decision: 'deny',
tarpitDelayMs: highestTarpitDelay,
isThrottled,
tarpitDelayMs: tarpit.tarpitDelayMs,
isThrottled: tarpit.isThrottled,
reason: 'No matching route rule found (Default Deny).'
}
}
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-rbac/src/engine/TarpitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SubjectTrafficRecord> = new Map()
private readonly traffic: Map<string, SubjectTrafficRecord> = new Map()

/**
* Evaluates request traffic for a given subject key and returns the required tarpit delay or blocking decision.
Expand Down
4 changes: 3 additions & 1 deletion packages/auth-rbac/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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
} from './middlewares/ExpressRbacMiddleware'
export {
AstroRbacMiddleware,
type AstroRbacOptions,
type AstroUserResolver,
type AstroLikeContext,
type AstroLikeMiddlewareNext
} from './middlewares/AstroRbacMiddleware'
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
request: TRequest,
response: TResponse,
reason: 'unauthenticated' | 'forbidden' | 'tarpit_blocked'
): Promise<any> | any
): Promise<unknown> | unknown

Check warning on line 38 in packages/auth-rbac/src/middlewares/AbstractRbacMiddleware.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'unknown' overrides all other types in this union type.

See more on https://sonarcloud.io/project/issues?id=Quatrain_Core&issues=AaDP_E5rlfDPqyRz5wO0&open=AaDP_E5rlfDPqyRz5wO0&pullRequest=65

/**
* Factory method building a scoped `RbacRequestContext` helper for controllers and templates.
Expand Down
27 changes: 16 additions & 11 deletions packages/auth-rbac/src/middlewares/AstroRbacMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ export interface AstroLikeContext {

export type AstroLikeMiddlewareNext = () => Promise<Response>

/**
* Resolver function signature retrieving user context from Astro context.
*/
export type AstroUserResolver = (context: AstroLikeContext) => Promise<RbacUserContext | null> | 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> | 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") */
Expand All @@ -32,10 +37,10 @@ export interface AstroRbacOptions {
* Unifies API endpoints (JSON responses) and SSR pages (redirects / forbidden status).
*/
export class AstroRbacMiddleware extends AbstractRbacMiddleware<AstroLikeContext, Response> {
private userResolver?: (context: AstroLikeContext) => Promise<RbacUserContext | null> | 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)
Expand Down Expand Up @@ -143,13 +148,13 @@ export class AstroRbacMiddleware extends AbstractRbacMiddleware<AstroLikeContext
await this.engine.tarpitManager.sleep(evaluation.tarpitDelayMs)
}

// 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(context, null, reason)
}
Expand Down
23 changes: 14 additions & 9 deletions packages/auth-rbac/src/middlewares/ExpressRbacMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | 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> | RbacUserContext | null
userResolver?: ExpressUserResolver
/** Whether to inject tarpit delay asynchronously before calling next() */
enableTarpitSleep?: boolean
}
Expand All @@ -46,8 +51,8 @@ export class ExpressRbacMiddleware extends AbstractRbacMiddleware<
ExpressLikeResponse,
ExpressLikeNextFunction
> {
private userResolver?: (req: ExpressLikeRequest) => Promise<RbacUserContext | null> | RbacUserContext | null
private enableTarpitSleep: boolean
private readonly userResolver?: ExpressUserResolver
private readonly enableTarpitSleep: boolean

constructor(engine: any, options: ExpressRbacOptions = {}) {
super(engine)
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading