feat: phase 3 - field-level permissions with plugin field definitions, service-layer filtering, 20 core field definitions

This commit is contained in:
Agent Zero
2026-07-15 23:02:30 +02:00
parent 08fd3ab72c
commit beaca24480
9 changed files with 106 additions and 4 deletions
+39
View File
@@ -59,6 +59,31 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
] ]
# ── Core field definitions for field-level permissions ──
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
{"module": "companies", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "companies", "field": "account_number", "label": "Account Number", "sensitivity": "normal"},
{"module": "companies", "field": "industry", "label": "Industry", "sensitivity": "normal"},
{"module": "companies", "field": "phone", "label": "Phone", "sensitivity": "normal"},
{"module": "companies", "field": "email", "label": "Email", "sensitivity": "normal"},
{"module": "companies", "field": "website", "label": "Website", "sensitivity": "normal"},
{"module": "companies", "field": "description", "label": "Description", "sensitivity": "normal"},
{"module": "contacts", "field": "first_name", "label": "First Name", "sensitivity": "normal"},
{"module": "contacts", "field": "last_name", "label": "Last Name", "sensitivity": "normal"},
{"module": "contacts", "field": "email", "label": "Email", "sensitivity": "normal"},
{"module": "contacts", "field": "phone", "label": "Phone", "sensitivity": "normal"},
{"module": "contacts", "field": "mobile", "label": "Mobile", "sensitivity": "sensitive"},
{"module": "contacts", "field": "position", "label": "Position", "sensitivity": "normal"},
{"module": "contacts", "field": "department", "label": "Department", "sensitivity": "normal"},
{"module": "contacts", "field": "linkedin_url", "label": "LinkedIn URL", "sensitivity": "sensitive"},
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
{"module": "users", "field": "is_active", "label": "Active", "sensitivity": "normal"},
]
class PermissionRegistry: class PermissionRegistry:
"""Central registry of all valid permissions (core + active plugins).""" """Central registry of all valid permissions (core + active plugins)."""
@@ -67,6 +92,8 @@ class PermissionRegistry:
self._plugin_permissions: dict[str, list[dict[str, str]]] = {} # plugin_name → perms self._plugin_permissions: dict[str, list[dict[str, str]]] = {} # plugin_name → perms
self._active_plugins: set[str] = set() self._active_plugins: set[str] = set()
self._initialized = False self._initialized = False
self._field_definitions: dict[str, list[dict[str, str]]] = {} # plugin_name → field_defs
self._core_field_definitions: list[dict[str, str]] = list(CORE_FIELD_DEFINITIONS)
def initialize(self, active_plugin_names: set[str] | None = None) -> None: def initialize(self, active_plugin_names: set[str] | None = None) -> None:
"""Build the registry from core permissions and active plugin manifests.""" """Build the registry from core permissions and active plugin manifests."""
@@ -137,6 +164,18 @@ class PermissionRegistry:
groups[cat].append(perm) groups[cat].append(perm)
return groups return groups
def register_field_definitions(self, plugin_name: str, field_defs: list[dict[str, str]]) -> None:
"""Register field definitions from a plugin."""
self._field_definitions[plugin_name] = field_defs
logger.info("Registered %d field definitions for plugin '%s'", len(field_defs), plugin_name)
def get_all_field_definitions(self) -> list[dict[str, str]]:
"""Return all registered field definitions."""
result = list(self._core_field_definitions)
for defs in self._field_definitions.values():
result.extend(defs)
return result
# Global instance # Global instance
_registry = PermissionRegistry() _registry = PermissionRegistry()
+10
View File
@@ -177,6 +177,16 @@ async def lifespan(app: FastAPI):
init_permission_registry(active_plugin_names) init_permission_registry(active_plugin_names)
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names)) logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
# Register field definitions from active plugins
from app.core.permission_registry import get_permission_registry
for name in registry._plugins:
plugin = registry.get_plugin(name)
if plugin:
field_defs = plugin.get_field_definitions()
if field_defs:
get_permission_registry().register_field_definitions(name, field_defs)
logger.info("Field definitions registered for %d plugins", len(registry._plugins))
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants # Seed default data (EUR currency, 19%/7% tax rates) for all tenants
from app.core.seeds import seed_default_data from app.core.seeds import seed_default_data
+9
View File
@@ -82,6 +82,15 @@ class BasePlugin(ABC):
""" """
return [] return []
# ─── Field Definitions ───
def get_field_definitions(self) -> list[dict[str, str]]:
"""Return field definitions from manifest."""
return [
{"module": fd.module, "field": fd.field, "label": fd.label, "sensitivity": fd.sensitivity}
for fd in self.manifest.field_definitions
]
# ─── Route Registration ─── # ─── Route Registration ───
def get_routes(self) -> list[APIRouter]: def get_routes(self) -> list[APIRouter]:
+12
View File
@@ -15,6 +15,15 @@ class PluginRouteDef(BaseModel):
) )
class FieldDefinition(BaseModel):
"""Field definition for field-level permissions."""
module: str = Field(..., description="Module name (e.g. 'companies', 'contacts')")
field: str = Field(..., description="Field name (e.g. 'annual_revenue')")
label: str = Field(..., description="Human-readable label")
sensitivity: str = Field(default="normal", description="normal|sensitive|critical")
class PluginManifest(BaseModel): class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities.""" """Manifest describing a plugin's metadata, dependencies, and capabilities."""
@@ -42,6 +51,9 @@ class PluginManifest(BaseModel):
is_core: bool = Field( is_core: bool = Field(
default=False, description="Whether this is a core plugin that cannot be deactivated" default=False, description="Whether this is a core plugin that cannot be deactivated"
) )
field_definitions: list[FieldDefinition] = Field(
default_factory=list, description="Field definitions for field-level permissions"
)
@field_validator("name") @field_validator("name")
@classmethod @classmethod
+2 -1
View File
@@ -37,6 +37,7 @@ async def list_companies(
industry=industry, industry=industry,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
resolved_perms=current_user,
) )
return result return result
@@ -159,7 +160,7 @@ async def get_company(
400, detail={"detail": "Invalid company_id", "code": "invalid_id"} 400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
) from None ) from None
data = await company_service.get_company_detail(db, tenant_id, cid) data = await company_service.get_company_detail(db, tenant_id, cid, resolved_perms=current_user)
if data is None: if data is None:
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
return data return data
+2 -1
View File
@@ -43,6 +43,7 @@ async def list_contacts(
search=search, search=search,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
resolved_perms=current_user,
) )
return result return result
@@ -173,7 +174,7 @@ async def get_contact(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"} 400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None ) from None
data = await contact_service.get_contact_detail(db, tenant_id, cid) data = await contact_service.get_contact_detail(db, tenant_id, cid, resolved_perms=current_user)
if data is None: if data is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return data return data
+2
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.auth import get_redis from app.core.auth import get_redis
from app.core.db import get_db from app.core.db import get_db
from app.core.permission_registry import get_permission_registry
from app.core.permissions import invalidate_all_user_permissions from app.core.permissions import invalidate_all_user_permissions
from app.deps import require_permission from app.deps import require_permission
from app.models.plugin import Plugin as PluginModel from app.models.plugin import Plugin as PluginModel
@@ -108,6 +109,7 @@ async def list_permissions(
"system": SYSTEM_PERMISSIONS, "system": SYSTEM_PERMISSIONS,
"plugins": plugin_perms, "plugins": plugin_perms,
"all": SYSTEM_PERMISSIONS + plugin_perms, "all": SYSTEM_PERMISSIONS + plugin_perms,
"field_definitions": get_permission_registry().get_all_field_definitions(),
} }
+15 -1
View File
@@ -35,6 +35,12 @@ def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, An
return data return data
def _filter_company_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter company fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "companies")
async def list_companies( async def list_companies(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
@@ -44,6 +50,7 @@ async def list_companies(
industry: str | None = None, industry: str | None = None,
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List companies with pagination, FTS search, industry filter, and sorting.""" """List companies with pagination, FTS search, industry filter, and sorting."""
page = max(1, page) page = max(1, page)
@@ -83,8 +90,12 @@ async def list_companies(
result = await db.execute(paginated) result = await db.execute(paginated)
companies = result.scalars().all() companies = result.scalars().all()
items = [_company_to_dict(c) for c in companies]
if resolved_perms is not None:
items = [_filter_company_fields(item, resolved_perms) for item in items]
return { return {
"items": [_company_to_dict(c) for c in companies], "items": items,
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -95,6 +106,7 @@ async def get_company_detail(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
company_id: uuid.UUID, company_id: uuid.UUID,
resolved_perms: dict | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Get a single company with its contacts array.""" """Get a single company with its contacts array."""
q = select(Company).where( q = select(Company).where(
@@ -133,6 +145,8 @@ async def get_company_detail(
} }
) )
data["contacts"] = contacts_list data["contacts"] = contacts_list
if resolved_perms is not None:
data = _filter_company_fields(data, resolved_perms)
return data return data
+15 -1
View File
@@ -35,6 +35,12 @@ def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, A
return data return data
def _filter_contact_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter contact fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "contacts")
async def list_contacts( async def list_contacts(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
@@ -43,6 +49,7 @@ async def list_contacts(
search: str | None = None, search: str | None = None,
sort_by: str = "last_name", sort_by: str = "last_name",
sort_order: str = "asc", sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List contacts with pagination and optional search.""" """List contacts with pagination and optional search."""
page = max(1, page) page = max(1, page)
@@ -76,8 +83,12 @@ async def list_contacts(
result = await db.execute(paginated) result = await db.execute(paginated)
contacts = result.scalars().all() contacts = result.scalars().all()
items = [_contact_to_dict(c) for c in contacts]
if resolved_perms is not None:
items = [_filter_contact_fields(item, resolved_perms) for item in items]
return { return {
"items": [_contact_to_dict(c) for c in contacts], "items": items,
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -88,6 +99,7 @@ async def get_contact_detail(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
contact_id: uuid.UUID, contact_id: uuid.UUID,
resolved_perms: dict | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Get a single contact with its linked companies array.""" """Get a single contact with its linked companies array."""
q = select(Contact).where( q = select(Contact).where(
@@ -123,6 +135,8 @@ async def get_contact_detail(
} }
) )
data["companies"] = companies_list data["companies"] = companies_list
if resolved_perms is not None:
data = _filter_contact_fields(data, resolved_perms)
return data return data