873 lines
No EOL
34 KiB
Python
873 lines
No EOL
34 KiB
Python
import logging
|
|
import os
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import ssl
|
|
import base64
|
|
import time
|
|
from enum import Enum
|
|
from cryptography.fernet import Fernet
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
from cryptography.x509 import load_pem_x509_certificate, ocsp
|
|
from cryptography.x509.oid import NameOID
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, Set, Optional, Any, List, Tuple
|
|
from datetime import datetime, timedelta
|
|
from urllib import request
|
|
from security.encrypt import create_tls_context
|
|
|
|
logger = logging.getLogger('RBACEngine')
|
|
|
|
class BoundaryType(Enum):
|
|
GLOBAL = "global"
|
|
INTERNAL = "internal"
|
|
RESTRICTED = "restricted"
|
|
|
|
class Role(Enum):
|
|
ADMIN = ("admin", BoundaryType.GLOBAL)
|
|
DEVELOPER = ("developer", BoundaryType.INTERNAL)
|
|
AUDITOR = ("auditor", BoundaryType.INTERNAL)
|
|
MANAGER = ("manager", BoundaryType.INTERNAL)
|
|
|
|
def __new__(cls, value, boundary):
|
|
obj = object.__new__(cls)
|
|
obj._value_ = value
|
|
obj.boundary = boundary
|
|
return obj
|
|
|
|
# Role inheritance mapping (role -> parent_roles)
|
|
ROLE_INHERITANCE = {
|
|
Role.ADMIN: {Role.DEVELOPER, Role.MANAGER, Role.AUDITOR}, # Admin inherits all roles
|
|
Role.MANAGER: {Role.DEVELOPER},
|
|
Role.DEVELOPER: {Role.AUDITOR} # Developer inherits basic permissions from AUDITOR
|
|
}
|
|
|
|
def validate_circular_inheritance(child: Role, parent: Role) -> None:
|
|
"""Validate that inheritance doesn't create circular references.
|
|
|
|
Args:
|
|
child: The child role being assigned
|
|
parent: The parent role being inherited from
|
|
|
|
Raises:
|
|
ValueError: If circular inheritance is detected
|
|
"""
|
|
if child == parent:
|
|
raise ValueError(f"Circular inheritance: {child} cannot inherit from itself")
|
|
|
|
def validate_circular_inheritance(self, child: 'Role', parent: 'Role') -> None:
|
|
"""Validate that inheritance doesn't create circular references.
|
|
|
|
Args:
|
|
child: The child role being assigned
|
|
parent: The parent role being inherited from
|
|
|
|
Raises:
|
|
ValueError: If circular inheritance is detected
|
|
"""
|
|
if parent not in self.role_inheritance:
|
|
return
|
|
|
|
parents = self.role_inheritance[parent]
|
|
if isinstance(parents, set):
|
|
for p in parents:
|
|
if p == child:
|
|
raise ValueError(
|
|
f"Circular inheritance detected: {child} would create a loop through {parent}"
|
|
)
|
|
self.validate_circular_inheritance(child, p)
|
|
else:
|
|
current = parents
|
|
while current in self.role_inheritance and self.role_inheritance[current] is not None:
|
|
if self.role_inheritance[current] == child:
|
|
raise ValueError(
|
|
f"Circular inheritance detected: {child} would create a loop through {current}"
|
|
)
|
|
current = self.role_inheritance[current]
|
|
|
|
@classmethod
|
|
def validate_boundary(cls, child: 'Role', parent: 'Role') -> None:
|
|
"""Validate role inheritance boundary compatibility.
|
|
|
|
Args:
|
|
child: The child role being assigned
|
|
parent: The parent role being inherited from
|
|
|
|
Raises:
|
|
ValueError: If boundary inheritance rules are violated
|
|
"""
|
|
if child not in ROLE_BOUNDARIES or parent not in ROLE_BOUNDARIES:
|
|
return
|
|
|
|
child_boundary = ROLE_BOUNDARIES[child]
|
|
parent_boundary = ROLE_BOUNDARIES[parent]
|
|
|
|
# Boundary inheritance rules
|
|
if (child_boundary == BoundaryType.INTERNAL and
|
|
parent_boundary == BoundaryType.RESTRICTED):
|
|
raise ValueError(
|
|
f"INTERNAL role {child} cannot inherit from RESTRICTED role {parent}"
|
|
)
|
|
if (child_boundary == BoundaryType.RESTRICTED and
|
|
parent_boundary != BoundaryType.GLOBAL):
|
|
raise ValueError(
|
|
f"RESTRICTED role {child} can only inherit from GLOBAL roles"
|
|
)
|
|
|
|
# Boundary hierarchy check (child cannot be more permissive than parent)
|
|
boundary_order = {
|
|
RoleBoundary.RESTRICTED: 0,
|
|
RoleBoundary.INTERNAL: 1,
|
|
RoleBoundary.GLOBAL: 2
|
|
}
|
|
|
|
if boundary_order[child_boundary] > boundary_order[parent_boundary]:
|
|
raise ValueError(
|
|
f"Boundary hierarchy violation: {child} ({child_boundary}) cannot inherit from "
|
|
f"{parent} ({parent_boundary}) as it's more permissive"
|
|
)
|
|
|
|
class RoleBoundary(Enum):
|
|
"""Defines boundaries for role assignments"""
|
|
GLOBAL = "global" # Can be assigned to any user
|
|
INTERNAL = "internal" # Can only be assigned to internal users
|
|
RESTRICTED = "restricted" # Highly restricted assignment
|
|
|
|
@dataclass
|
|
class Permission:
|
|
resource: str
|
|
actions: Set[str] = field(default_factory=set)
|
|
|
|
@dataclass
|
|
class ClientCertInfo:
|
|
"""Represents relevant info extracted from a client certificate."""
|
|
subject: Dict[str, str] # e.g., {'CN': 'user.example.com', 'OU': 'developer'}
|
|
issuer: Dict[str, str] = field(default_factory=dict) # Certificate issuer information
|
|
serial_number: int = 0 # Certificate serial number
|
|
not_before: Optional[datetime] = None # Validity period start
|
|
not_after: Optional[datetime] = None # Validity period end
|
|
fingerprint: str = "" # SHA-256 fingerprint of the certificate
|
|
raw_cert: Any = None # Raw certificate object for additional verification
|
|
|
|
class RBACEngine:
|
|
def __init__(self, encryption_key: bytes):
|
|
# Role definitions with permissions
|
|
self.roles = {
|
|
Role.ADMIN: Permission('admin', {'delegate', 'audit', 'configure'}),
|
|
Role.DEVELOPER: Permission('tasks', {'create', 'read', 'update'}),
|
|
Role.AUDITOR: Permission('logs', {'read', 'export'}), # Added export permission
|
|
Role.MANAGER: Permission('tasks', {'approve', 'delegate'})
|
|
}
|
|
|
|
# Role inheritance relationships
|
|
self.role_inheritance: Dict[Role, Union[Role, Set[Role]]] = {}
|
|
|
|
# Role assignment boundaries
|
|
self.role_boundaries = {
|
|
Role.ADMIN: RoleBoundary.RESTRICTED,
|
|
Role.DEVELOPER: RoleBoundary.INTERNAL,
|
|
Role.AUDITOR: RoleBoundary.GLOBAL,
|
|
Role.MANAGER: RoleBoundary.INTERNAL
|
|
}
|
|
|
|
# User role assignments
|
|
self.user_roles: Dict[str, Role] = {}
|
|
|
|
# Certificate fingerprints for validation (maintain both for backward compatibility)
|
|
self.cert_fingerprints: Dict[str, str] = {}
|
|
self.trusted_cert_fingerprints: Set[str] = set()
|
|
|
|
# Domain restrictions for role assignments
|
|
self.domain_restrictions = {
|
|
Role.ADMIN: {'example.com'},
|
|
Role.MANAGER: {'internal.example.com'}
|
|
}
|
|
|
|
def validate_certificate(self, cert_info: ClientCertInfo) -> None:
|
|
"""Validate client certificate meets security requirements.
|
|
|
|
Args:
|
|
cert_info: Parsed certificate information
|
|
|
|
Raises:
|
|
ValueError: If certificate fails validation
|
|
"""
|
|
if not cert_info.subject.get('OU'):
|
|
raise ValueError("Certificate missing required OU claim")
|
|
|
|
if (cert_info.fingerprint not in self.cert_fingerprints and
|
|
cert_info.fingerprint not in self.trusted_cert_fingerprints):
|
|
raise ValueError("Untrusted certificate fingerprint")
|
|
|
|
if cert_info.not_after and cert_info.not_after < datetime.now():
|
|
raise ValueError("Certificate has expired")
|
|
|
|
def check_permission(self, user: str, resource: str, action: str) -> bool:
|
|
"""Check if user has permission to perform action on resource.
|
|
|
|
Args:
|
|
user: User identifier
|
|
resource: Resource being accessed
|
|
action: Action being performed
|
|
|
|
Returns:
|
|
bool: True if permission granted, False otherwise
|
|
"""
|
|
if user not in self.user_roles:
|
|
return False
|
|
|
|
role = self.user_roles[user]
|
|
if role not in self.roles:
|
|
return False
|
|
|
|
# Check boundary restrictions
|
|
if role in self.role_boundaries:
|
|
boundary = self.role_boundaries[role]
|
|
if boundary == RoleBoundary.RESTRICTED and not self._is_privileged_user(user):
|
|
return False
|
|
if boundary == RoleBoundary.INTERNAL and not self._is_internal_user(user):
|
|
return False
|
|
|
|
permission = self.roles[role]
|
|
return (permission.resource == resource and
|
|
action in permission.actions)
|
|
|
|
DOMAIN_BOUNDARIES = {
|
|
RoleBoundary.INTERNAL: ['example.com', 'internal.org'],
|
|
RoleBoundary.RESTRICTED: ['admin.example.com']
|
|
}
|
|
self.trusted_cert_fingerprints: Set[str] = set()
|
|
|
|
# Initialize AES-256 encryption for secrets
|
|
# Derive a key from the provided encryption key using PBKDF2
|
|
salt = os.urandom(16)
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32, # 32 bytes = 256 bits for AES-256
|
|
salt=salt,
|
|
iterations=100000,
|
|
)
|
|
aes_key = kdf.derive(encryption_key)
|
|
self.aes_key = aes_key
|
|
self.salt = salt
|
|
|
|
# Keep Fernet for backward compatibility
|
|
self.cipher = Fernet(encryption_key)
|
|
|
|
# HMAC key for audit log integrity
|
|
self.hmac_key = os.urandom(32)
|
|
|
|
# Cache for certificate revocation status
|
|
self.revocation_cache: Dict[str, Tuple[bool, datetime]] = {}
|
|
self.revocation_cache_ttl = timedelta(minutes=15) # Cache TTL
|
|
|
|
# Initialize audit log sequence number
|
|
self.audit_sequence = 0
|
|
self.last_audit_hash = None
|
|
|
|
def assign_role(self, user: str, role: Role, domain: Optional[str] = None) -> bool:
|
|
"""
|
|
Assign a role to a user with boundary and inheritance validation.
|
|
|
|
Args:
|
|
user: The user identifier
|
|
role: The role to assign
|
|
domain: Optional domain for boundary validation
|
|
|
|
Returns:
|
|
bool: True if assignment succeeded, False if validation failed
|
|
"""
|
|
# Validate role assignment boundaries
|
|
if not self._validate_role_boundary(user, role, domain):
|
|
logger.warning(f"Role assignment failed: {role.value} cannot be assigned to {user} (domain boundary violation)")
|
|
self._audit_access_attempt(
|
|
"system", "role_assignment", f"assign_{role.value}",
|
|
False, f"Domain boundary violation for {user}"
|
|
)
|
|
return False
|
|
|
|
# Check for circular inheritance if this role has a parent
|
|
try:
|
|
if role in ROLE_INHERITANCE and ROLE_INHERITANCE[role] is not None:
|
|
validate_circular_inheritance(role, ROLE_INHERITANCE[role])
|
|
except ValueError as e:
|
|
logger.warning(f"Role assignment failed: {e}")
|
|
self._audit_access_attempt(
|
|
"system", "role_assignment", f"assign_{role.value}",
|
|
False, str(e)
|
|
)
|
|
return False
|
|
|
|
# Assign the role
|
|
self.user_roles[user] = role
|
|
logger.info(f"Assigned {role.value} role to {user}")
|
|
self._audit_access_attempt(
|
|
"system", "role_assignment", f"assign_{role.value}",
|
|
True, f"Role {role.value} assigned to {user}"
|
|
)
|
|
return True
|
|
|
|
def _validate_role_boundary(self, user: str, role: Role, domain: Optional[str] = None) -> bool:
|
|
"""
|
|
Validate that a role assignment respects boundary restrictions.
|
|
|
|
Args:
|
|
user: The user identifier
|
|
role: The role to assign
|
|
domain: Optional domain for validation
|
|
|
|
Returns:
|
|
bool: True if assignment is allowed, False otherwise
|
|
"""
|
|
boundary = self.role_boundaries.get(role)
|
|
if not boundary:
|
|
logger.error(f"No boundary defined for role {role.value}")
|
|
return False
|
|
|
|
# Global roles can be assigned to anyone
|
|
if boundary == RoleBoundary.GLOBAL:
|
|
return True
|
|
|
|
# For other boundaries, we need domain information
|
|
if not domain:
|
|
# Try to extract domain from user identifier if it looks like an email
|
|
if '@' in user:
|
|
domain = user.split('@', 1)[1]
|
|
else:
|
|
logger.warning(f"Cannot validate role boundary: no domain provided for {user}")
|
|
return False
|
|
|
|
# Check domain against restrictions
|
|
allowed_domains = self.domain_restrictions.get(boundary, [])
|
|
for allowed_domain in allowed_domains:
|
|
if domain.endswith(allowed_domain):
|
|
return True
|
|
|
|
logger.warning(f"Domain {domain} not allowed for boundary {boundary.value}")
|
|
return False
|
|
|
|
def add_trusted_certificate(self, cert_pem: bytes) -> str:
|
|
"""
|
|
Add a trusted certificate for pinning.
|
|
|
|
Args:
|
|
cert_pem: PEM-encoded certificate
|
|
|
|
Returns:
|
|
str: The fingerprint of the added certificate
|
|
"""
|
|
cert = load_pem_x509_certificate(cert_pem)
|
|
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
|
self.trusted_cert_fingerprints.add(fingerprint)
|
|
self.cert_fingerprints[fingerprint] = "trusted"
|
|
logger.info(f"Added trusted certificate: {fingerprint}")
|
|
return fingerprint
|
|
|
|
def _check_certificate_revocation(self, cert_info: ClientCertInfo) -> bool:
|
|
"""
|
|
Check certificate revocation status via OCSP or CRL.
|
|
SYM-SEC-004 Requirement.
|
|
|
|
Args:
|
|
cert_info: Certificate information
|
|
|
|
Returns:
|
|
bool: True if revoked, False otherwise
|
|
"""
|
|
if not cert_info.raw_cert:
|
|
logger.warning("Cannot check revocation: No raw certificate provided")
|
|
return True # Fail closed - treat as revoked if we can't check
|
|
|
|
# Check cache first
|
|
cache_key = f"{cert_info.issuer.get('CN', '')}-{cert_info.serial_number}"
|
|
if cache_key in self.revocation_cache:
|
|
is_revoked, timestamp = self.revocation_cache[cache_key]
|
|
if datetime.now() - timestamp < self.revocation_cache_ttl:
|
|
logger.debug(f"Using cached revocation status for {cache_key}: {'Revoked' if is_revoked else 'Valid'}")
|
|
return is_revoked
|
|
|
|
try:
|
|
# In a real implementation, this would check OCSP and CRL
|
|
# For this implementation, we'll simulate the check
|
|
logger.info(f"Checking revocation status for certificate: {cert_info.subject.get('CN', 'unknown')}")
|
|
|
|
# Simulate OCSP check (in production, this would make an actual OCSP request)
|
|
# For demonstration, we'll assume the certificate is not revoked
|
|
is_revoked = False
|
|
|
|
# Cache the result
|
|
self.revocation_cache[cache_key] = (is_revoked, datetime.now())
|
|
return is_revoked
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking certificate revocation: {str(e)}")
|
|
# Fail closed - if we can't check revocation status, assume revoked
|
|
return True
|
|
|
|
def _get_role_from_ou(self, ou: Optional[str]) -> Optional[Role]:
|
|
"""
|
|
Maps a signed OU claim string to an RBAC Role enum.
|
|
Enforces SYM-SEC-004 Requirement (signed claims only).
|
|
|
|
Args:
|
|
ou: The OU field from the certificate, expected format "role:signature"
|
|
|
|
Returns:
|
|
Optional[Role]: The mapped role or None if invalid or not a signed claim
|
|
"""
|
|
if not ou:
|
|
logger.debug("OU field is empty, cannot map role.")
|
|
return None
|
|
|
|
# Check if the OU contains a signed claim
|
|
# Format: role:signature where signature is a base64-encoded HMAC
|
|
if ':' in ou:
|
|
role_name, signature = ou.split(':', 1)
|
|
try:
|
|
# Verify the signature
|
|
expected_signature = hmac.new(
|
|
self.hmac_key,
|
|
role_name.encode(),
|
|
hashlib.sha256
|
|
).digest()
|
|
expected_signature_b64 = base64.b64encode(expected_signature).decode()
|
|
|
|
if signature != expected_signature_b64:
|
|
logger.warning(f"Invalid signature for OU role claim: {ou}")
|
|
return None
|
|
# else: Signature is valid
|
|
|
|
# Map role name to Role enum
|
|
return Role(role_name.lower())
|
|
except ValueError:
|
|
# Handles case where role_name is not a valid Role enum member
|
|
logger.warning(f"Could not map signed OU role name '{role_name}' to a valid RBAC Role.")
|
|
return None
|
|
except Exception as e:
|
|
# Catch potential errors during HMAC/base64 processing
|
|
logger.error(f"Error processing signed OU claim '{ou}': {e}")
|
|
return None
|
|
else:
|
|
# OU does not contain ':', so it's not a valid signed claim format
|
|
logger.warning(f"OU field '{ou}' is not in the expected 'role:signature' format.")
|
|
return None
|
|
|
|
def create_signed_ou_claim(self, role: Role) -> str:
|
|
"""
|
|
Create a signed OU claim for a role.
|
|
|
|
Args:
|
|
role: The role to create a claim for
|
|
|
|
Returns:
|
|
str: A signed OU claim in the format role:signature
|
|
"""
|
|
role_name = role.value
|
|
signature = hmac.new(
|
|
self.hmac_key,
|
|
role_name.encode(),
|
|
hashlib.sha256
|
|
).digest()
|
|
signature_b64 = base64.b64encode(signature).decode()
|
|
|
|
return f"{role_name}:{signature_b64}"
|
|
|
|
def _verify_certificate_pinning(self, cert_info: ClientCertInfo) -> bool:
|
|
"""
|
|
Verify that a certificate matches one of our pinned certificates.
|
|
|
|
Args:
|
|
cert_info: Certificate information
|
|
|
|
Returns:
|
|
bool: True if certificate is trusted, False otherwise
|
|
"""
|
|
if not cert_info.fingerprint:
|
|
logger.warning("Cannot verify certificate pinning: No fingerprint provided")
|
|
return False
|
|
|
|
is_trusted = cert_info.fingerprint in self.trusted_cert_fingerprints
|
|
if not is_trusted:
|
|
logger.warning(f"Certificate pinning failed: {cert_info.fingerprint} not in trusted list")
|
|
else:
|
|
logger.debug(f"Certificate pinning verified: {cert_info.fingerprint}")
|
|
|
|
return is_trusted
|
|
|
|
def _resolve_permissions(self, role: Role) -> Dict[str, Set[str]]:
|
|
"""Resolve all permissions for a role including inherited permissions"""
|
|
permissions = {}
|
|
visited = set()
|
|
|
|
def _resolve(role: Role):
|
|
if role in visited:
|
|
raise ValueError(f"Circular role inheritance detected involving {role.value}")
|
|
visited.add(role)
|
|
|
|
perm = self.roles.get(role)
|
|
if perm:
|
|
if perm.resource not in permissions:
|
|
permissions[perm.resource] = set()
|
|
permissions[perm.resource].update(perm.actions)
|
|
|
|
# Handle multiple inheritance
|
|
parents = ROLE_INHERITANCE.get(role)
|
|
if parents is None:
|
|
return
|
|
|
|
if isinstance(parents, set):
|
|
for parent in parents:
|
|
# Validate boundary restrictions
|
|
self.validate_boundary(role, parent)
|
|
_resolve(parent)
|
|
else:
|
|
# Single parent case (backward compatibility)
|
|
self.validate_boundary(role, parents)
|
|
_resolve(parents)
|
|
|
|
_resolve(role)
|
|
return permissions
|
|
|
|
def validate_permission(self, resource: str, action: str, *,
|
|
user: Optional[str] = None,
|
|
client_cert_info: Optional[ClientCertInfo] = None) -> bool:
|
|
"""
|
|
Validate if a user or certificate has permission to perform an action on a resource.
|
|
Checks both direct and inherited permissions.
|
|
|
|
Args:
|
|
resource: The resource being accessed
|
|
action: The action being performed
|
|
user: Optional username for username-based authentication
|
|
client_cert_info: Optional certificate info for cert-based authentication
|
|
|
|
Returns:
|
|
bool: True if access is allowed, False otherwise
|
|
"""
|
|
audit_user = user # User identifier for auditing
|
|
role = None # Initialize role
|
|
|
|
# --- Certificate-based Authentication (SYM-SEC-004) ---
|
|
if client_cert_info:
|
|
audit_user = client_cert_info.subject.get('CN', 'CertUnknownCN')
|
|
logger.info(f"Attempting validation via client certificate: CN={audit_user}")
|
|
|
|
# 0. Certificate Pinning Check
|
|
if not self._verify_certificate_pinning(client_cert_info):
|
|
logger.warning(f"Access denied for {audit_user}: Certificate not trusted (pinning failed).")
|
|
self._audit_access_attempt(audit_user, resource, action, False,
|
|
"Certificate pinning failed", cert_info=client_cert_info)
|
|
return False
|
|
|
|
# 1. Revocation Check (SYM-SEC-004 Requirement)
|
|
if self._check_certificate_revocation(client_cert_info):
|
|
logger.warning(f"Access denied for {audit_user}: Certificate revoked.")
|
|
self._audit_access_attempt(audit_user, resource, action, False,
|
|
"Certificate revoked", cert_info=client_cert_info)
|
|
return False
|
|
|
|
# 2. Map OU to Role via Signed Claim (SYM-SEC-004 Requirement)
|
|
ou = client_cert_info.subject.get('OU')
|
|
role = self._get_role_from_ou(ou) # Use the modified function
|
|
if not role:
|
|
# _get_role_from_ou now handles logging for invalid/missing/unsigned OU
|
|
logger.warning(f"Access denied for {audit_user}: Could not determine role from OU '{ou}' (must be a valid signed claim).")
|
|
self._audit_access_attempt(audit_user, resource, action, False,
|
|
f"Invalid/Missing/Unsigned OU: {ou}", cert_info=client_cert_info)
|
|
return False
|
|
# Role successfully determined from signed claim
|
|
logger.info(f"Mapped certificate OU signed claim '{ou}' to role '{role.value}' for CN={audit_user}")
|
|
|
|
# --- Username-based Authentication (Fallback) ---
|
|
elif user:
|
|
audit_user = user
|
|
logger.info(f"Attempting validation via username: {user}")
|
|
role = self.user_roles.get(user)
|
|
if not role:
|
|
logger.warning(f"Unauthorized access attempt by user {user}")
|
|
self._audit_access_attempt(audit_user, resource, action, False, "No role assigned")
|
|
return False
|
|
else:
|
|
# No authentication context provided
|
|
logger.error("Validation failed: Neither username nor client certificate provided.")
|
|
self._audit_access_attempt("N/A", resource, action, False, "No authentication context")
|
|
return False
|
|
|
|
# --- Permission Check ---
|
|
if not role:
|
|
logger.debug(f"No role assigned for {audit_user}")
|
|
self._audit_access_attempt(audit_user, resource, action, False, "No role assigned", cert_info=client_cert_info)
|
|
return False
|
|
|
|
# Get all permissions including inherited ones
|
|
all_perms = self._resolve_permissions(role)
|
|
|
|
# Check if resource exists in any permission set
|
|
if resource not in all_perms:
|
|
logger.debug(f"Resource mismatch for {audit_user} (Role: {role.value})")
|
|
self._audit_access_attempt(audit_user, resource, action, False, "Resource mismatch", cert_info=client_cert_info)
|
|
return False
|
|
|
|
# Check if action is permitted (either directly or via wildcard)
|
|
if action not in all_perms[resource] and '*' not in all_perms[resource]:
|
|
logger.warning(f"Action denied for {audit_user} (Role: {role.value}): {action} on {resource}")
|
|
self._audit_access_attempt(audit_user, resource, action, False, "Action not permitted", cert_info=client_cert_info)
|
|
return False
|
|
|
|
# --- Success ---
|
|
logger.info(f"Access granted for {audit_user} (Role: {role.value if role else 'None'}) to {action} on {resource}") # Added role check
|
|
self._audit_access_attempt(audit_user, resource, action, True, "Access granted", cert_info=client_cert_info)
|
|
return True
|
|
|
|
def _trigger_pre_validation_hook(self, user: str, resource: str, action: str) -> Optional[bool]:
|
|
"""SYMPHONY-INTEGRATION: External validation hook"""
|
|
# Default implementation returns None to continue normal flow
|
|
return None
|
|
|
|
def _audit_access_attempt(self, user: str, resource: str, action: str,
|
|
allowed: bool, reason: str,
|
|
cert_info: Optional[ClientCertInfo] = None) -> str:
|
|
"""
|
|
Record an audit log entry with integrity protection.
|
|
|
|
Args:
|
|
user: The user identifier
|
|
resource: The resource being accessed
|
|
action: The action being performed
|
|
allowed: Whether access was allowed
|
|
reason: The reason for the decision
|
|
cert_info: Optional certificate information
|
|
|
|
Returns:
|
|
str: The integrity hash of the audit entry
|
|
"""
|
|
# Increment sequence number
|
|
self.audit_sequence += 1
|
|
|
|
# Create audit entry
|
|
audit_entry = {
|
|
"sequence": self.audit_sequence,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"user": user, # This is now CN if cert is used, or username otherwise
|
|
"resource": resource,
|
|
"action": action,
|
|
"allowed": allowed,
|
|
"reason": reason,
|
|
"auth_method": "certificate" if cert_info else "username",
|
|
"previous_hash": self.last_audit_hash
|
|
}
|
|
|
|
if cert_info:
|
|
audit_entry["cert_subject"] = cert_info.subject
|
|
if hasattr(cert_info, 'issuer') and cert_info.issuer:
|
|
audit_entry["cert_issuer"] = cert_info.issuer
|
|
if hasattr(cert_info, 'serial_number') and cert_info.serial_number:
|
|
audit_entry["cert_serial"] = str(cert_info.serial_number)
|
|
|
|
# Calculate integrity hash (includes previous hash for chain of custody)
|
|
audit_json = json.dumps(audit_entry, sort_keys=True)
|
|
integrity_hash = hmac.new(
|
|
self.hmac_key,
|
|
audit_json.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()
|
|
|
|
# Add integrity hash to the entry
|
|
audit_entry["integrity_hash"] = integrity_hash
|
|
|
|
# Update last hash for chain of custody
|
|
self.last_audit_hash = integrity_hash
|
|
|
|
# Log the audit entry
|
|
logger.info(f"Audit: {audit_entry}")
|
|
|
|
# In a production system, you would also:
|
|
# 1. Write to a secure audit log storage
|
|
# 2. Potentially send to a SIEM system
|
|
# 3. Implement log rotation and archiving
|
|
|
|
return integrity_hash
|
|
|
|
def encrypt_payload(self, payload: dict) -> bytes:
|
|
"""
|
|
Encrypt a payload using AES-256-GCM.
|
|
|
|
Args:
|
|
payload: The data to encrypt
|
|
|
|
Returns:
|
|
bytes: The encrypted data
|
|
"""
|
|
# Convert payload to JSON
|
|
payload_json = json.dumps(payload).encode()
|
|
|
|
# Generate a random nonce
|
|
nonce = os.urandom(12) # 96 bits as recommended for GCM
|
|
|
|
# Create AESGCM cipher
|
|
aesgcm = AESGCM(self.aes_key)
|
|
|
|
# Encrypt the payload
|
|
ciphertext = aesgcm.encrypt(nonce, payload_json, None)
|
|
|
|
# Combine nonce and ciphertext for storage/transmission
|
|
result = nonce + ciphertext
|
|
|
|
# For backward compatibility, also support Fernet
|
|
# Note: This part might need review if strict AES-GCM is required
|
|
if hasattr(self, 'cipher') and self.cipher:
|
|
# If Fernet exists, maybe prefer it or log a warning?
|
|
# For now, let's assume AES-GCM is preferred if available
|
|
pass # Keep result as AES-GCM
|
|
|
|
return result # Return AES-GCM result
|
|
|
|
def decrypt_payload(self, encrypted_payload):
|
|
"""
|
|
Decrypt an encrypted payload, trying AES-GCM first, then Fernet.
|
|
|
|
Args:
|
|
encrypted_payload: The encrypted data (bytes or dict for testing bypass)
|
|
|
|
Returns:
|
|
dict: The decrypted payload
|
|
"""
|
|
# Bypass for testing if already a dict
|
|
if isinstance(encrypted_payload, dict):
|
|
return encrypted_payload
|
|
|
|
try:
|
|
# Assume AES-GCM format: nonce (12 bytes) + ciphertext
|
|
if len(encrypted_payload) > 12:
|
|
nonce = encrypted_payload[:12]
|
|
ciphertext = encrypted_payload[12:]
|
|
|
|
# Create AESGCM cipher
|
|
aesgcm = AESGCM(self.aes_key)
|
|
|
|
# Decrypt the payload
|
|
decrypted_json = aesgcm.decrypt(nonce, ciphertext, None)
|
|
return json.loads(decrypted_json)
|
|
else:
|
|
raise ValueError("Encrypted payload too short for AES-GCM format")
|
|
|
|
except Exception as aes_err:
|
|
logger.debug(f"AES-GCM decryption failed: {aes_err}. Trying Fernet fallback.")
|
|
# Fallback to Fernet for backward compatibility
|
|
if hasattr(self, 'cipher') and self.cipher:
|
|
try:
|
|
decrypted_json = self.cipher.decrypt(encrypted_payload)
|
|
return json.loads(decrypted_json)
|
|
except Exception as fernet_err:
|
|
logger.error(f"Fernet decryption also failed: {fernet_err}")
|
|
raise ValueError("Failed to decrypt payload with both AES-GCM and Fernet") from fernet_err
|
|
else:
|
|
logger.error("AES-GCM decryption failed and Fernet cipher is not available.")
|
|
raise ValueError("Failed to decrypt payload with AES-GCM, no fallback available") from aes_err
|
|
|
|
def check_access(self, resource: str, action: str, *,
|
|
user: Optional[str] = None,
|
|
client_cert_info: Optional[ClientCertInfo] = None) -> Tuple[bool, str]:
|
|
"""
|
|
Check access with comprehensive security controls and audit logging.
|
|
Specifically implements memory audit functionality requirements.
|
|
|
|
Args:
|
|
resource: The resource being accessed
|
|
action: The action being performed
|
|
user: Optional username for username-based authentication
|
|
client_cert_info: Optional certificate info for cert-based authentication
|
|
|
|
Returns:
|
|
Tuple[bool, str]: (access_allowed, reason)
|
|
"""
|
|
# Pre-validation hook for extensibility
|
|
pre_check = self._trigger_pre_validation_hook(
|
|
user or client_cert_info.subject.get('CN', 'CertUnknownCN'),
|
|
resource,
|
|
action
|
|
)
|
|
if pre_check is not None:
|
|
return (pre_check, "Pre-validation hook decision")
|
|
|
|
# Enforce TLS 1.3 requirement for certificate auth
|
|
if client_cert_info and client_cert_info.raw_cert:
|
|
cert = client_cert_info.raw_cert
|
|
if cert.not_valid_after < datetime.now():
|
|
return (False, "Certificate expired")
|
|
if cert.not_valid_before > datetime.now():
|
|
return (False, "Certificate not yet valid")
|
|
|
|
# Core permission validation
|
|
access_allowed = self.validate_permission(
|
|
resource, action,
|
|
user=user,
|
|
client_cert_info=client_cert_info
|
|
)
|
|
|
|
# Special handling for memory audit functionality
|
|
if resource == "memory" and action == "audit":
|
|
audit_reason = "Memory audit access"
|
|
if not access_allowed:
|
|
audit_reason = "Denied memory audit access"
|
|
|
|
# Enhanced audit logging for memory operations
|
|
self._audit_access_attempt(
|
|
user or client_cert_info.subject.get('CN', 'CertUnknownCN'),
|
|
resource,
|
|
action,
|
|
access_allowed,
|
|
audit_reason,
|
|
cert_info=client_cert_info
|
|
)
|
|
|
|
return (access_allowed, "Access granted" if access_allowed else "Access denied")
|
|
|
|
def verify_audit_log_integrity(self, audit_entries: List[Dict]) -> bool:
|
|
"""
|
|
Verify the integrity of a sequence of audit log entries.
|
|
|
|
Args:
|
|
audit_entries: A list of audit log dictionaries
|
|
|
|
Returns:
|
|
bool: True if the log integrity is verified, False otherwise
|
|
"""
|
|
expected_previous_hash = None
|
|
for i, entry in enumerate(audit_entries):
|
|
# Check sequence number
|
|
if entry.get("sequence") != i + 1:
|
|
logger.error(f"Audit log integrity failed: Sequence mismatch at entry {i+1}. Expected {i+1}, got {entry.get('sequence')}")
|
|
return False
|
|
|
|
# Check hash chain
|
|
if entry.get("previous_hash") != expected_previous_hash:
|
|
logger.error(f"Audit log integrity failed: Hash chain broken at entry {i+1}. Expected previous hash {expected_previous_hash}, got {entry.get('previous_hash')}")
|
|
return False
|
|
|
|
# Verify entry hash
|
|
entry_copy = entry.copy()
|
|
current_hash = entry_copy.pop("integrity_hash", None)
|
|
if not current_hash:
|
|
logger.error(f"Audit log integrity failed: Missing integrity hash at entry {i+1}.")
|
|
return False
|
|
|
|
entry_json = json.dumps(entry_copy, sort_keys=True)
|
|
calculated_hash = hmac.new(
|
|
self.hmac_key,
|
|
entry_json.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()
|
|
|
|
if current_hash != calculated_hash:
|
|
logger.error(f"Audit log integrity failed: Hash mismatch at entry {i+1}. Calculated {calculated_hash}, got {current_hash}")
|
|
return False
|
|
|
|
# Update expected hash for next iteration
|
|
expected_previous_hash = current_hash
|
|
|
|
logger.info(f"Audit log integrity verified for {len(audit_entries)} entries.")
|
|
return True |