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:
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user