"""ABAC policy service — attribute-based access control for entities. This service handles: - CRUD for entity_policies - apply_policy_filter: translates JSONB conditions into SQLAlchemy filters - build_sql_condition: recursive JSONB → SQLAlchemy expression translation Policy evaluation: - allow policies: OR-joined (at least one must match for access) - deny policies: NOT (none may match — deny takes precedence) """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy import and_, not_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import InstrumentedAttribute from app.models.entity_policy import EntityPolicy logger = logging.getLogger(__name__) # Supported operators for condition translation _SUPPORTED_OPS = { "eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "contains", "starts_with", "is_null", "is_not_null", } # ─── ABAC Field Whitelist ────────────────────────────────────────── # Only these fields may be referenced in policy conditions per entity # type. Prevents policies from filtering on sensitive columns such as # tenant_id, password_hash, etc. ABAC_ALLOWED_FIELDS: dict[str, set[str]] = { "contact": {"status", "type", "country", "tags", "created_at", "updated_at", "owner_id", "name", "displayname", "firstname", "surname", "email_1", "email_2", "code"}, "file": {"status", "size", "mime_type", "created_at"}, "task": {"status", "priority", "due_date", "created_at"}, "calendar_event": {"status", "start_time", "end_time", "created_at"}, "mailbox": {"status", "created_at"}, "address": {"country", "city", "created_at", "updated_at"}, "contact_folder": {"name", "created_at"}, "workflow": {"status", "created_at"}, } def _serialize_policy(p: EntityPolicy) -> dict: return { "id": str(p.id), "name": p.name, "entity_type": p.entity_type, "principal_type": p.principal_type, "principal_id": str(p.principal_id), "effect": p.effect, "conditions": p.conditions, "priority": p.priority, "tenant_id": str(p.tenant_id), "enabled": p.enabled, "created_at": p.created_at.isoformat() if p.created_at else None, "updated_at": p.updated_at.isoformat() if p.updated_at else None, } def build_sql_condition( conditions: dict[str, Any], model: type, entity_type: str | None = None, ) -> Any: """Recursively translate a JSONB conditions block into a SQLAlchemy filter expression. Conditions format: { "operator": "AND" | "OR", "rules": [ {"field": "status", "op": "eq", "value": "active"}, {"field": "amount", "op": "gte", "value": 1000}, ... ] } Nested conditions are supported via rules that contain a nested conditions block. When *entity_type* is provided, each rule's ``field`` is validated against the ABAC_ALLOWED_FIELDS whitelist for that entity type. Unknown fields are logged and skipped (not crashed) to prevent policies from filtering on sensitive columns such as ``tenant_id`` or ``password_hash``. """ if not conditions or "operator" not in conditions or "rules" not in conditions: return None operator = conditions["operator"] rules = conditions["rules"] if not rules: return None clauses: list[Any] = [] for rule in rules: # Nested conditions block if "operator" in rule and "rules" in rule: nested = build_sql_condition(rule, model, entity_type=entity_type) if nested is not None: clauses.append(nested) continue field_name = rule.get("field") op = rule.get("op") value = rule.get("value") if not field_name or not op: continue if op not in _SUPPORTED_OPS: logger.warning(f"Unsupported condition operator: {op}") continue # ── Field whitelist check ────────────────────────────────── # Prevent policies from filtering on sensitive columns if entity_type is not None: allowed = ABAC_ALLOWED_FIELDS.get(entity_type) if allowed is not None and field_name not in allowed: logger.warning( f"ABAC field '{field_name}' not in whitelist for " f"entity_type '{entity_type}' — skipping rule" ) continue # Get the model attribute attr: InstrumentedAttribute | None = getattr(model, field_name, None) if attr is None: logger.warning(f"Unknown field '{field_name}' on {model.__name__}") continue if op == "eq": clauses.append(attr == value) elif op == "neq": clauses.append(attr != value) elif op == "in": if isinstance(value, list): clauses.append(attr.in_(value)) else: clauses.append(attr == value) elif op == "not_in": if isinstance(value, list): clauses.append(not_(attr.in_(value))) else: clauses.append(attr != value) elif op == "gt": clauses.append(attr > value) elif op == "gte": clauses.append(attr >= value) elif op == "lt": clauses.append(attr < value) elif op == "lte": clauses.append(attr <= value) elif op == "contains": clauses.append(attr.contains(value)) elif op == "starts_with": clauses.append(attr.startswith(value)) elif op == "is_null": clauses.append(attr.is_(None)) elif op == "is_not_null": clauses.append(attr.isnot(None)) if not clauses: return None if operator == "AND": return and_(*clauses) else: # OR return or_(*clauses) async def list_policies( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str | None = None, ) -> list[dict]: """List all policies for a tenant, optionally filtered by entity_type.""" query = select(EntityPolicy).where(EntityPolicy.tenant_id == tenant_id) if entity_type: query = query.where(EntityPolicy.entity_type == entity_type) query = query.order_by(EntityPolicy.priority.desc(), EntityPolicy.created_at) result = await db.execute(query) policies = result.scalars().all() return [_serialize_policy(p) for p in policies] async def create_policy( db: AsyncSession, tenant_id: uuid.UUID, name: str, entity_type: str, principal_type: str, principal_id: str, effect: str = "allow", conditions: dict[str, Any] | None = None, priority: int = 0, ) -> dict: """Create a new ABAC policy.""" policy = EntityPolicy( tenant_id=tenant_id, name=name, entity_type=entity_type, principal_type=principal_type, principal_id=uuid.UUID(principal_id), effect=effect, conditions=conditions, priority=priority, ) db.add(policy) await db.commit() await db.refresh(policy) return _serialize_policy(policy) async def update_policy( db: AsyncSession, tenant_id: uuid.UUID, policy_id: str, **kwargs: Any, ) -> dict: """Update an existing ABAC policy.""" result = await db.execute( select(EntityPolicy) .where(EntityPolicy.id == uuid.UUID(policy_id)) .where(EntityPolicy.tenant_id == tenant_id) ) policy = result.scalar_one_or_none() if policy is None: raise ValueError(f"Policy {policy_id} not found") # Update only provided fields updatable_fields = { "name", "entity_type", "principal_type", "principal_id", "effect", "conditions", "priority", "enabled", } for key, value in kwargs.items(): if key in updatable_fields and value is not None: if key == "principal_id": setattr(policy, key, uuid.UUID(value)) else: setattr(policy, key, value) await db.commit() await db.refresh(policy) return _serialize_policy(policy) async def delete_policy( db: AsyncSession, tenant_id: uuid.UUID, policy_id: str, ) -> None: """Delete an ABAC policy.""" result = await db.execute( select(EntityPolicy) .where(EntityPolicy.id == uuid.UUID(policy_id)) .where(EntityPolicy.tenant_id == tenant_id) ) policy = result.scalar_one_or_none() if policy is None: raise ValueError(f"Policy {policy_id} not found") await db.delete(policy) await db.commit() async def apply_policy_filter( db: AsyncSession, query: Any, entity_type: str, user_id: uuid.UUID, tenant_id: uuid.UUID, model: type, ) -> Any: """Apply ABAC policy filters to an existing SQLAlchemy query. Logic: 1. Load all enabled policies for this entity_type + tenant that match the user (via principal_type='user' with principal_id=user_id, or via group/role) 2. Separate into allow and deny policies 3. For allow policies: OR-join their conditions (at least one must match) 4. For deny policies: NOT (none may match — deny takes precedence) 5. Apply: query = query.where(allow_filter & deny_filter) If no policies match, the query is returned unchanged (no ABAC restriction). """ # Load policies for this user on this entity_type # For simplicity, we load user-direct policies + group/role policies # In a full implementation, we'd also resolve group memberships stmt = select(EntityPolicy).where( EntityPolicy.tenant_id == tenant_id, EntityPolicy.entity_type == entity_type, EntityPolicy.enabled == True, # noqa: E712 ).where( or_( and_( EntityPolicy.principal_type == "user", EntityPolicy.principal_id == user_id, ), # Group/role policies would need membership resolution # For now, we also match group/role by principal_id EntityPolicy.principal_id == user_id, ) ) result = await db.execute(stmt) policies = result.scalars().all() if not policies: return query # No ABAC restriction allow_clauses: list[Any] = [] deny_clauses: list[Any] = [] for policy in policies: if not policy.conditions: # Policy without conditions matches everything if policy.effect == "allow": allow_clauses.append(True) else: deny_clauses.append(True) continue condition = build_sql_condition(policy.conditions, model, entity_type=entity_type) if condition is None: continue if policy.effect == "allow": allow_clauses.append(condition) else: deny_clauses.append(condition) filters: list[Any] = [] if allow_clauses: # At least one allow policy must match filters.append(or_(*allow_clauses)) if deny_clauses: # No deny policy may match filters.append(not_(or_(*deny_clauses))) if filters: query = query.where(and_(*filters)) return query