| export interface PolicyNode { |
| name: string; |
| children: PolicyNode[]; |
| type: string; // Changed to dynamic string type based on model parameters |
| level: number; |
| policyType?: 'p' | 'g' | 'g2' | 'g3'; |
| parameterIndex?: number; // Added to track parameter position in model |
| } |
| |
| export interface PolicyRelation { |
| source: string; |
| target: string; |
| type: 'p' | 'g' | 'g2' | 'g3'; |
| action?: string; |
| domain?: string; |
| effect?: 'allow' | 'deny'; |
| lineIndex?: number; |
| rawLine?: string; |
| } |
| |
| export class PolicyInheritanceParser { |
| private relations: PolicyRelation[] = []; |
| private visited: Set<string> = new Set(); |
| private visiting: Set<string> = new Set(); |
| private nodeMap: Map<string, PolicyNode> = new Map(); |
| private modelParameters: string[] = []; // Added to store model parameters |
| |
| /** |
| * Parse the policy text and extract all policy relationships (P policy + G policy) |
| * Now supports model-aware parsing |
| */ |
| parsePolicy(policyText: string, modelText?: string): void { |
| this.relations = []; |
| this.nodeMap.clear(); |
| |
| // Parse model configuration if provided |
| if (modelText) { |
| this.parseModelConfig(modelText); |
| } |
| |
| const rawLines = policyText.split('\n'); |
| rawLines.forEach((originalLine, idx) => { |
| const line = (originalLine || '').trim(); |
| if (!line || line.startsWith('#')) return; |
| |
| if (line.startsWith('p,') || line.startsWith('p ')) { |
| const relation = this.parsePolicyRule(line); |
| if (relation) { |
| relation.lineIndex = idx; |
| relation.rawLine = originalLine; |
| this.relations.push(relation); |
| } |
| } else if (line.match(/^g[0-9]*[,\s]/)) { |
| const relation = this.parseGroupRule(line); |
| if (relation) { |
| relation.lineIndex = idx; |
| relation.rawLine = originalLine; |
| this.relations.push(relation); |
| } |
| } |
| }); |
| } |
| |
| /** |
| * Return parsed relations with metadata (lineIndex, rawLine) |
| */ |
| getRelations(): PolicyRelation[] { |
| return this.relations; |
| } |
| /** |
| * Parse a policy rule (P strategy) and extract relationships |
| */ |
| private parsePolicyRule(line: string): PolicyRelation | null { |
| const parts = line.split(',').map((part) => { |
| return part.trim(); |
| }); |
| if (parts.length < 3) return null; |
| |
| const [, subject, object, action, effect] = parts; |
| |
| return { |
| source: subject, |
| target: object, |
| type: 'p', |
| action, |
| effect: effect === 'deny' ? 'deny' : effect === 'allow' ? 'allow' : undefined, |
| }; |
| } |
| |
| /** |
| * Parse a group rule (G strategy) and extract relationships |
| */ |
| private parseGroupRule(line: string): PolicyRelation | null { |
| const parts = line.split(',').map((part) => { |
| return part.trim(); |
| }); |
| if (parts.length < 3) return null; |
| |
| const ruleType = parts[0]; |
| const [, source, target, domain] = parts; |
| |
| return { |
| source, |
| target, |
| type: ruleType as 'g' | 'g2' | 'g3', |
| domain, |
| }; |
| } |
| |
| /** |
| * Parse Casbin model configuration to extract parameter definitions |
| */ |
| private parseModelConfig(modelText: string): void { |
| this.modelParameters = []; |
| |
| const lines = modelText.split('\n'); |
| let inRequestDefinition = false; |
| |
| for (const line of lines) { |
| const trimmedLine = line.trim(); |
| |
| // Check for request_definition section |
| if (trimmedLine === '[request_definition]') { |
| inRequestDefinition = true; |
| continue; |
| } |
| |
| // Check for other sections (exit request_definition) |
| if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']') && trimmedLine !== '[request_definition]') { |
| inRequestDefinition = false; |
| continue; |
| } |
| |
| // Parse request definition line |
| if (inRequestDefinition && trimmedLine.startsWith('r = ')) { |
| const paramString = trimmedLine.substring(4).trim(); |
| this.modelParameters = paramString.split(',').map((param) => { |
| return param.trim(); |
| }); |
| break; |
| } |
| } |
| |
| // Fallback to default parameters if parsing fails |
| if (this.modelParameters.length === 0) { |
| this.modelParameters = ['sub', 'obj', 'act']; |
| } |
| } |
| |
| /** |
| * Determine node type based on model parameters, position, and G rule relationships |
| */ |
| private determineNodeType(nodeId: string, parameterIndex?: number): string { |
| // First, check if this entity is a target in any G rule - if so, it's a role |
| const isRoleTarget = this.relations.some((rel) => { |
| return rel.type.startsWith('g') && rel.target === nodeId; |
| }); |
| if (isRoleTarget) { |
| return 'role'; |
| } |
| |
| // Check if this entity only appears in G rules as a source (user inheriting roles) |
| const onlyInGRulesAsSource = this.relations.every((rel) => { |
| if (rel.source === nodeId) { |
| return rel.type.startsWith('g'); |
| } |
| if (rel.target === nodeId) { |
| return false; // appears as target somewhere |
| } |
| if (rel.action === nodeId || rel.domain === nodeId) { |
| return false; // appears in other positions |
| } |
| return true; // not in this relation |
| }) && this.relations.some((rel) => { |
| return rel.type.startsWith('g') && rel.source === nodeId; |
| }); |
| |
| if (onlyInGRulesAsSource) { |
| return 'user'; |
| } |
| |
| // If we have model parameters and parameter index, use model-based typing |
| if (this.modelParameters.length > 0 && parameterIndex !== undefined && parameterIndex < this.modelParameters.length) { |
| return this.modelParameters[parameterIndex]; |
| } |
| |
| // Use position-based typing when parameter index is known from P policy rules |
| if (parameterIndex !== undefined) { |
| switch (parameterIndex) { |
| case 0: |
| return 'user'; // subject position - always user |
| case 1: |
| return 'resource'; // object position - default to resource |
| case 2: |
| return 'action'; // action position |
| default: |
| return 'object'; // other positions |
| } |
| } |
| |
| // Fallback to heuristic-based typing for backward compatibility |
| return this.determineNodeTypeHeuristic(nodeId); |
| } |
| |
| /** |
| * Heuristic-based node type determination (fallback method) |
| */ |
| private determineNodeTypeHeuristic(nodeId: string): string { |
| const lowerNodeId = nodeId.toLowerCase(); |
| |
| // User patterns |
| if (lowerNodeId.includes('user') || lowerNodeId.includes('alice') || lowerNodeId.includes('bob') || lowerNodeId.includes('charlie')) { |
| return 'user'; |
| } |
| |
| // Role patterns |
| if (lowerNodeId.includes('admin') || lowerNodeId.includes('role') || lowerNodeId.includes('manager') || lowerNodeId.includes('group')) { |
| return 'role'; |
| } |
| |
| // Resource patterns |
| if (lowerNodeId.includes('data') || lowerNodeId.includes('file') || lowerNodeId.includes('resource') || lowerNodeId.includes('document')) { |
| return 'resource'; |
| } |
| |
| // Action patterns |
| if ( |
| lowerNodeId.includes('read') || |
| lowerNodeId.includes('write') || |
| lowerNodeId.includes('delete') || |
| lowerNodeId.includes('get') || |
| lowerNodeId.includes('post') |
| ) { |
| return 'action'; |
| } |
| |
| // Default to object for unrecognized patterns |
| return 'object'; |
| } |
| /** |
| * Build policy graph from parsed relations |
| */ |
| buildPolicyGraph(): PolicyNode[] { |
| const rootNodes: PolicyNode[] = []; |
| |
| // Create nodes for all entities |
| const allEntities = new Set<string>(); |
| this.relations.forEach((rel) => { |
| allEntities.add(rel.source); |
| allEntities.add(rel.target); |
| }); |
| |
| allEntities.forEach((entity) => { |
| if (!this.nodeMap.has(entity)) { |
| const paramIndex = this.getParameterIndex(entity); |
| const node: PolicyNode = { |
| name: entity, |
| children: [], |
| type: this.determineNodeType(entity, paramIndex), |
| level: 0, |
| parameterIndex: paramIndex, |
| }; |
| this.nodeMap.set(entity, node); |
| } |
| }); |
| |
| // Build hierarchy for G relations |
| this.relations |
| .filter((rel) => { |
| return rel.type.startsWith('g'); |
| }) |
| .forEach((rel) => { |
| const parentNode = this.nodeMap.get(rel.source); |
| const childNode = this.nodeMap.get(rel.target); |
| if (parentNode && childNode) { |
| parentNode.children.push(childNode); |
| childNode.level = parentNode.level + 1; |
| } |
| }); |
| |
| // Find root nodes (nodes with no parents) |
| const childNodes = new Set<string>(); |
| this.relations |
| .filter((rel) => { |
| return rel.type.startsWith('g'); |
| }) |
| .forEach((rel) => { |
| return childNodes.add(rel.target); |
| }); |
| |
| this.nodeMap.forEach((node, id) => { |
| if (!childNodes.has(id)) { |
| rootNodes.push(node); |
| } |
| }); |
| |
| return rootNodes; |
| } |
| |
| /** |
| * Get parameter index for a node based on its position in policy rules |
| */ |
| private getParameterIndex(nodeId: string): number | undefined { |
| // Find the parameter index by checking P policy rules |
| for (const rel of this.relations) { |
| if (rel.type === 'p') { |
| if (rel.source === nodeId) return 0; // subject position |
| if (rel.target === nodeId) return 1; // object position |
| if (rel.action === nodeId) return 2; // action position |
| if (rel.domain === nodeId) return 3; // domain position |
| } |
| } |
| return undefined; |
| } |
| |
| /** |
| * Get connections grouped by type |
| */ |
| getConnectionsByType(): Record<string, PolicyRelation[]> { |
| const connections: Record<string, PolicyRelation[]> = {}; |
| |
| this.relations.forEach((rel) => { |
| if (!connections[rel.type]) { |
| connections[rel.type] = []; |
| } |
| connections[rel.type].push(rel); |
| }); |
| |
| return connections; |
| } |
| |
| /** |
| * Find circular dependencies in role inheritance |
| */ |
| findCircularDependencies(): string[][] { |
| this.visited.clear(); |
| const cycles: string[][] = []; |
| |
| // Get all unique node IDs from G relations |
| const nodeIds = new Set<string>(); |
| this.relations |
| .filter((rel) => { |
| return rel.type.startsWith('g'); |
| }) |
| .forEach((rel) => { |
| nodeIds.add(rel.source); |
| nodeIds.add(rel.target); |
| }); |
| |
| nodeIds.forEach((nodeId) => { |
| const path = new Set<string>(); |
| if (this.detectCycle(nodeId, path)) { |
| cycles.push(Array.from(path)); |
| } |
| }); |
| |
| return cycles; |
| } |
| |
| private detectCycle(nodeId: string, path: Set<string>): boolean { |
| if (path.has(nodeId)) return true; |
| if (this.visited.has(nodeId)) return false; |
| |
| path.add(nodeId); |
| |
| const children = this.relations |
| .filter((rel) => { |
| return rel.source === nodeId && rel.type.startsWith('g'); |
| }) |
| .map((rel) => { |
| return rel.target; |
| }); |
| |
| for (const child of children) { |
| if (this.detectCycle(child, path)) { |
| return true; |
| } |
| } |
| |
| path.delete(nodeId); |
| this.visited.add(nodeId); |
| return false; |
| } |
| } |