diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index ec522a1..5dbec6c 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -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() diff --git a/app/main.py b/app/main.py index f3d73d1..06907ff 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/plugins/base.py b/app/plugins/base.py index 3883357..d34660c 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -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]: diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 69d0ed0..e651788 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -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 diff --git a/app/routes/companies.py b/app/routes/companies.py index 7fcf0e0..e1deef9 100644 --- a/app/routes/companies.py +++ b/app/routes/companies.py @@ -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 diff --git a/app/routes/contacts.py b/app/routes/contacts.py index 3cdac69..c897dad 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -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 diff --git a/app/routes/roles.py b/app/routes/roles.py index 6349681..b3ec573 100644 --- a/app/routes/roles.py +++ b/app/routes/roles.py @@ -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(), } diff --git a/app/services/company_service.py b/app/services/company_service.py index 8d94453..3b315a2 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -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 diff --git a/app/services/contact_service.py b/app/services/contact_service.py index 25c0559..298f358 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -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