feat: phase 3 - field-level permissions with plugin field definitions, service-layer filtering, 20 core field definitions
This commit is contained in:
@@ -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:
|
||||
"""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._active_plugins: set[str] = set()
|
||||
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:
|
||||
"""Build the registry from core permissions and active plugin manifests."""
|
||||
@@ -137,6 +164,18 @@ class PermissionRegistry:
|
||||
groups[cat].append(perm)
|
||||
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
|
||||
_registry = PermissionRegistry()
|
||||
|
||||
+10
@@ -177,6 +177,16 @@ async def lifespan(app: FastAPI):
|
||||
init_permission_registry(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
|
||||
from app.core.seeds import seed_default_data
|
||||
|
||||
|
||||
@@ -82,6 +82,15 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
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 ───
|
||||
|
||||
def get_routes(self) -> list[APIRouter]:
|
||||
|
||||
@@ -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):
|
||||
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
||||
|
||||
@@ -42,6 +51,9 @@ class PluginManifest(BaseModel):
|
||||
is_core: bool = Field(
|
||||
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")
|
||||
@classmethod
|
||||
|
||||
@@ -37,6 +37,7 @@ async def list_companies(
|
||||
industry=industry,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
resolved_perms=current_user,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -159,7 +160,7 @@ async def get_company(
|
||||
400, detail={"detail": "Invalid company_id", "code": "invalid_id"}
|
||||
) 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:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
return data
|
||||
|
||||
@@ -43,6 +43,7 @@ async def list_contacts(
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
resolved_perms=current_user,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -173,7 +174,7 @@ async def get_contact(
|
||||
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
|
||||
) 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:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
return data
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis
|
||||
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.deps import require_permission
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
@@ -108,6 +109,7 @@ async def list_permissions(
|
||||
"system": SYSTEM_PERMISSIONS,
|
||||
"plugins": plugin_perms,
|
||||
"all": SYSTEM_PERMISSIONS + plugin_perms,
|
||||
"field_definitions": get_permission_registry().get_all_field_definitions(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, An
|
||||
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(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
@@ -44,6 +50,7 @@ async def list_companies(
|
||||
industry: str | None = None,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
resolved_perms: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
||||
page = max(1, page)
|
||||
@@ -83,8 +90,12 @@ async def list_companies(
|
||||
result = await db.execute(paginated)
|
||||
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 {
|
||||
"items": [_company_to_dict(c) for c in companies],
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -95,6 +106,7 @@ async def get_company_detail(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
resolved_perms: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a single company with its contacts array."""
|
||||
q = select(Company).where(
|
||||
@@ -133,6 +145,8 @@ async def get_company_detail(
|
||||
}
|
||||
)
|
||||
data["contacts"] = contacts_list
|
||||
if resolved_perms is not None:
|
||||
data = _filter_company_fields(data, resolved_perms)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, A
|
||||
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(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
@@ -43,6 +49,7 @@ async def list_contacts(
|
||||
search: str | None = None,
|
||||
sort_by: str = "last_name",
|
||||
sort_order: str = "asc",
|
||||
resolved_perms: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List contacts with pagination and optional search."""
|
||||
page = max(1, page)
|
||||
@@ -76,8 +83,12 @@ async def list_contacts(
|
||||
result = await db.execute(paginated)
|
||||
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 {
|
||||
"items": [_contact_to_dict(c) for c in contacts],
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -88,6 +99,7 @@ async def get_contact_detail(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
contact_id: uuid.UUID,
|
||||
resolved_perms: dict | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a single contact with its linked companies array."""
|
||||
q = select(Contact).where(
|
||||
@@ -123,6 +135,8 @@ async def get_contact_detail(
|
||||
}
|
||||
)
|
||||
data["companies"] = companies_list
|
||||
if resolved_perms is not None:
|
||||
data = _filter_contact_fields(data, resolved_perms)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user