sprint12+13: zentrale rechte settings page + ABAC engine backend (model, migration 0055, service, routes)
This commit is contained in:
@@ -62,6 +62,7 @@ from app.routes import (
|
||||
webhooks,
|
||||
backups,
|
||||
owner_transfer,
|
||||
policies,
|
||||
)
|
||||
|
||||
|
||||
@@ -403,6 +404,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(saved_filters.router)
|
||||
app.include_router(saved_views.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(policies.router)
|
||||
app.include_router(errors.router)
|
||||
|
||||
# ── Register plugin routes for all built-in plugins ──
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_folder_permission import ContactFolderPermission
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.currency import Currency
|
||||
@@ -51,6 +52,7 @@ __all__ = [
|
||||
"ContactFolderPermission",
|
||||
"ContactMergeHistory",
|
||||
"EntityPermission",
|
||||
"EntityPolicy",
|
||||
"OwnedMixin",
|
||||
"EntityHistory",
|
||||
"Currency",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""ABAC entity policy model — attribute-based access control policies.
|
||||
|
||||
Each policy defines a rule for a specific entity type:
|
||||
- allow policies: at least one must match for access
|
||||
- deny policies: if any matches, access is denied (deny takes precedence)
|
||||
|
||||
Conditions use JSONB with format:
|
||||
{
|
||||
"operator": "AND" | "OR",
|
||||
"rules": [
|
||||
{"field": "status", "op": "eq", "value": "active"},
|
||||
{"field": "amount", "op": "gte", "value": 1000},
|
||||
{"field": "tags", "op": "contains", "value": "vip"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class EntityPolicy(Base, TenantMixin):
|
||||
"""ABAC policy entry for any entity type in the system.
|
||||
|
||||
entity_type examples: 'contact', 'dms_file', 'mailbox', 'calendar_event',
|
||||
'task', 'workflow', 'contact_folder', etc.
|
||||
|
||||
principal_type: 'user', 'group', 'role'
|
||||
|
||||
effect: 'allow' | 'deny'
|
||||
- allow: grants access if conditions match
|
||||
- deny: blocks access if conditions match (deny takes precedence over allow)
|
||||
|
||||
conditions: JSONB with operator (AND/OR) and rules array
|
||||
priority: higher priority policies are evaluated first
|
||||
"""
|
||||
|
||||
__tablename__ = "entity_policies"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"principal_type IN ('user', 'group', 'role')",
|
||||
name="ck_epol_principal_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"effect IN ('allow', 'deny')",
|
||||
name="ck_epol_effect",
|
||||
),
|
||||
Index("ix_epol_entity_type", "entity_type"),
|
||||
Index("ix_epol_principal", "principal_type", "principal_id"),
|
||||
Index("ix_epol_tenant", "tenant_id"),
|
||||
Index("ix_epol_priority", "priority"),
|
||||
Index("ix_epol_enabled", "enabled"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
principal_type: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
principal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=False
|
||||
)
|
||||
effect: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default="allow"
|
||||
)
|
||||
conditions: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, default=None
|
||||
)
|
||||
priority: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -150,6 +150,18 @@ async def get_entity_access(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
@require_permission("settings:read")
|
||||
async def list_all_permissions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List ALL permission entries for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
items = await entity_permission_service.list_all_permissions(db, tenant_id)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/registry")
|
||||
async def list_entity_registry(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""ABAC policy routes — attribute-based access control management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.policy import PolicyCreate, PolicyUpdate
|
||||
from app.services import policy_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/policies", tags=["policies"])
|
||||
|
||||
|
||||
@router.get("/{entity_type}")
|
||||
async def list_policies(
|
||||
entity_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all ABAC policies for a given entity type."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
items = await policy_service.list_policies(db, tenant_id, entity_type)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_policy(
|
||||
body: PolicyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new ABAC policy."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
return await policy_service.create_policy(
|
||||
db,
|
||||
tenant_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
principal_type=body.principal_type,
|
||||
principal_id=body.principal_id,
|
||||
effect=body.effect,
|
||||
conditions=body.conditions,
|
||||
priority=body.priority,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{policy_id}")
|
||||
async def update_policy(
|
||||
policy_id: str,
|
||||
body: PolicyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing ABAC policy."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
return await policy_service.update_policy(
|
||||
db,
|
||||
tenant_id,
|
||||
policy_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
principal_type=body.principal_type,
|
||||
principal_id=body.principal_id,
|
||||
effect=body.effect,
|
||||
conditions=body.conditions,
|
||||
priority=body.priority,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_policy(
|
||||
policy_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an ABAC policy."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
await policy_service.delete_policy(db, tenant_id, policy_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Schemas for ABAC entity policies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PolicyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
entity_type: str = Field(..., min_length=1, max_length=50)
|
||||
principal_type: str = Field(..., pattern="^(user|group|role)$")
|
||||
principal_id: str
|
||||
effect: str = Field("allow", pattern="^(allow|deny)$")
|
||||
conditions: dict[str, Any] | None = None
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class PolicyUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
entity_type: str | None = Field(None, min_length=1, max_length=50)
|
||||
principal_type: str | None = Field(None, pattern="^(user|group|role)$")
|
||||
principal_id: str | None = None
|
||||
effect: str | None = Field(None, pattern="^(allow|deny)$")
|
||||
conditions: dict[str, Any] | None = None
|
||||
priority: int | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class PolicyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
entity_type: str
|
||||
principal_type: str
|
||||
principal_id: str
|
||||
effect: str
|
||||
conditions: dict[str, Any] | None = None
|
||||
priority: int
|
||||
tenant_id: str
|
||||
enabled: bool
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
@@ -700,6 +700,21 @@ async def check_entity_access(
|
||||
return _rank(access) >= _rank(required_level)
|
||||
|
||||
|
||||
async def list_all_permissions(
|
||||
db: AsyncSession, tenant_id: uuid.UUID
|
||||
) -> list[dict]:
|
||||
"""List ALL permission entries for a tenant (global view)."""
|
||||
result = await db.execute(
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
.order_by(EntityPermission.entity_type, EntityPermission.created_at)
|
||||
)
|
||||
perms = result.scalars().all()
|
||||
|
||||
names = await _load_principal_names(db, perms)
|
||||
return [_serialize_permission(p, names.get(p.principal_id)) for p in perms]
|
||||
|
||||
|
||||
async def cleanup_expired_permissions(db: AsyncSession) -> int:
|
||||
"""Delete all expired permission entries. Returns count deleted."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""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",
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
) -> 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.
|
||||
"""
|
||||
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)
|
||||
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
|
||||
|
||||
# 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)
|
||||
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
|
||||
Reference in New Issue
Block a user