2026-07-29 02:42:16 +02:00
|
|
|
"""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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# ─── 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]] = {
|
2026-08-08 08:09:23 +02:00
|
|
|
"contact": {"status", "type", "country", "tags", "created_at", "updated_at", "owner_id", "name", "displayname", "firstname", "surname", "email_1", "email_2", "code"},
|
2026-08-06 22:05:15 +02:00
|
|
|
"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"},
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:42:16 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-08-06 22:05:15 +02:00
|
|
|
entity_type: str | None = None,
|
2026-07-29 02:42:16 +02:00
|
|
|
) -> 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.
|
2026-08-06 22:05:15 +02:00
|
|
|
|
|
|
|
|
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``.
|
2026-07-29 02:42:16 +02:00
|
|
|
"""
|
|
|
|
|
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:
|
2026-08-06 22:05:15 +02:00
|
|
|
nested = build_sql_condition(rule, model, entity_type=entity_type)
|
2026-07-29 02:42:16 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# ── 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
|
|
|
|
|
|
2026-07-29 02:42:16 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
condition = build_sql_condition(policy.conditions, model, entity_type=entity_type)
|
2026-07-29 02:42:16 +02:00
|
|
|
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
|