feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention
This commit is contained in:
@@ -72,6 +72,7 @@ async def create_session(
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"csrf_token": csrf_token,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Permission Registry — central catalog of all valid permissions.
|
||||
|
||||
Built at startup from core SYSTEM_PERMISSIONS and active plugin manifests.
|
||||
Validates permission assignments and provides metadata for UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Core system permissions ──
|
||||
CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "companies:read", "label": "Companies: Read", "category": "core", "module": "companies"},
|
||||
{"key": "companies:write", "label": "Companies: Write", "category": "core", "module": "companies"},
|
||||
{"key": "companies:delete", "label": "Companies: Delete", "category": "core", "module": "companies"},
|
||||
{"key": "contacts:read", "label": "Contacts: Read", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:write", "label": "Contacts: Write", "category": "core", "module": "contacts"},
|
||||
{"key": "contacts:delete", "label": "Contacts: Delete", "category": "core", "module": "contacts"},
|
||||
{"key": "users:read", "label": "Users: Read", "category": "core", "module": "users"},
|
||||
{"key": "users:write", "label": "Users: Write", "category": "core", "module": "users"},
|
||||
{"key": "users:delete", "label": "Users: Delete", "category": "core", "module": "users"},
|
||||
{"key": "roles:read", "label": "Roles: Read", "category": "core", "module": "roles"},
|
||||
{"key": "roles:write", "label": "Roles: Write", "category": "core", "module": "roles"},
|
||||
{"key": "roles:delete", "label": "Roles: Delete", "category": "core", "module": "roles"},
|
||||
{"key": "groups:read", "label": "Groups: Read", "category": "core", "module": "groups"},
|
||||
{"key": "groups:write", "label": "Groups: Write", "category": "core", "module": "groups"},
|
||||
{"key": "groups:delete", "label": "Groups: Delete", "category": "core", "module": "groups"},
|
||||
{"key": "audit:read", "label": "Audit Log: Read", "category": "core", "module": "audit"},
|
||||
{"key": "settings:write", "label": "Settings: Write", "category": "core", "module": "settings"},
|
||||
{"key": "plugins:install", "label": "Plugins: Install", "category": "core", "module": "plugins"},
|
||||
{"key": "plugins:configure", "label": "Plugins: Configure", "category": "core", "module": "plugins"},
|
||||
{"key": "tenants:read", "label": "Tenants: Read", "category": "core", "module": "tenants"},
|
||||
{"key": "tenants:write", "label": "Tenants: Write", "category": "core", "module": "tenants"},
|
||||
{"key": "tenants:delete", "label": "Tenants: Delete", "category": "core", "module": "tenants"},
|
||||
{"key": "notifications:read", "label": "Notifications: Read", "category": "core", "module": "notifications"},
|
||||
{"key": "notifications:write", "label": "Notifications: Write", "category": "core", "module": "notifications"},
|
||||
{"key": "attachments:read", "label": "Attachments: Read", "category": "core", "module": "attachments"},
|
||||
{"key": "attachments:write", "label": "Attachments: Write", "category": "core", "module": "attachments"},
|
||||
{"key": "attachments:delete", "label": "Attachments: Delete", "category": "core", "module": "attachments"},
|
||||
{"key": "workflows:read", "label": "Workflows: Read", "category": "core", "module": "workflows"},
|
||||
{"key": "workflows:write", "label": "Workflows: Write", "category": "core", "module": "workflows"},
|
||||
{"key": "sequences:read", "label": "Sequences: Read", "category": "core", "module": "sequences"},
|
||||
{"key": "sequences:write", "label": "Sequences: Write", "category": "core", "module": "sequences"},
|
||||
{"key": "addresses:read", "label": "Addresses: Read", "category": "core", "module": "addresses"},
|
||||
{"key": "addresses:write", "label": "Addresses: Write", "category": "core", "module": "addresses"},
|
||||
{"key": "addresses:delete", "label": "Addresses: Delete", "category": "core", "module": "addresses"},
|
||||
{"key": "taxes:read", "label": "Taxes: Read", "category": "core", "module": "taxes"},
|
||||
{"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"},
|
||||
{"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"},
|
||||
{"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"},
|
||||
{"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"},
|
||||
{"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
|
||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||
]
|
||||
|
||||
|
||||
class PermissionRegistry:
|
||||
"""Central registry of all valid permissions (core + active plugins)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._permissions: dict[str, dict[str, str]] = {}
|
||||
self._plugin_permissions: dict[str, list[dict[str, str]]] = {} # plugin_name → perms
|
||||
self._active_plugins: set[str] = set()
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, active_plugin_names: set[str] | None = None) -> None:
|
||||
"""Build the registry from core permissions and active plugin manifests."""
|
||||
self._permissions = {}
|
||||
self._plugin_permissions = {}
|
||||
self._active_plugins = active_plugin_names or set()
|
||||
|
||||
# Register core permissions
|
||||
for perm in CORE_PERMISSIONS:
|
||||
self._permissions[perm["key"]] = perm
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Permission registry initialized with %d core permissions", len(CORE_PERMISSIONS))
|
||||
|
||||
def register_plugin_permissions(self, plugin_name: str, permissions: list[str]) -> None:
|
||||
"""Register permissions from a plugin manifest."""
|
||||
plugin_perms: list[dict[str, str]] = []
|
||||
for perm in permissions:
|
||||
# Normalize: replace dots with colons
|
||||
normalized = perm.replace(".", ":")
|
||||
entry = {
|
||||
"key": normalized,
|
||||
"label": normalized.replace(":", ": ").title(),
|
||||
"category": "plugins",
|
||||
"plugin_name": plugin_name,
|
||||
"module": normalized.split(":")[0] if ":" in normalized else normalized,
|
||||
}
|
||||
plugin_perms.append(entry)
|
||||
self._permissions[normalized] = entry
|
||||
|
||||
self._plugin_permissions[plugin_name] = plugin_perms
|
||||
logger.info("Registered %d permissions for plugin '%s'", len(plugin_perms), plugin_name)
|
||||
|
||||
def unregister_plugin_permissions(self, plugin_name: str) -> None:
|
||||
"""Remove permissions for a deactivated/uninstalled plugin."""
|
||||
perms = self._plugin_permissions.pop(plugin_name, [])
|
||||
for p in perms:
|
||||
self._permissions.pop(p["key"], None)
|
||||
logger.info("Unregistered permissions for plugin '%s'", plugin_name)
|
||||
|
||||
def is_valid(self, permission: str) -> bool:
|
||||
"""Check if a permission key is known to the registry."""
|
||||
return permission in self._permissions
|
||||
|
||||
def is_plugin_active(self, plugin_name: str) -> bool:
|
||||
"""Check if a plugin is currently active."""
|
||||
return plugin_name in self._active_plugins
|
||||
|
||||
def get_all(self) -> list[dict[str, str]]:
|
||||
"""Return all registered permissions."""
|
||||
return list(self._permissions.values())
|
||||
|
||||
def get_core(self) -> list[dict[str, str]]:
|
||||
"""Return only core permissions."""
|
||||
return [p for p in self._permissions.values() if p.get("category") == "core"]
|
||||
|
||||
def get_plugin_permissions(self) -> list[dict[str, str]]:
|
||||
"""Return only plugin permissions."""
|
||||
return [p for p in self._permissions.values() if p.get("category") == "plugins"]
|
||||
|
||||
def get_grouped(self) -> dict[str, list[dict[str, str]]]:
|
||||
"""Return permissions grouped by category."""
|
||||
groups: dict[str, list[dict[str, str]]] = {}
|
||||
for perm in self._permissions.values():
|
||||
cat = perm.get("category", "core")
|
||||
if cat not in groups:
|
||||
groups[cat] = []
|
||||
groups[cat].append(perm)
|
||||
return groups
|
||||
|
||||
|
||||
# Global instance
|
||||
_registry = PermissionRegistry()
|
||||
|
||||
|
||||
def get_permission_registry() -> PermissionRegistry:
|
||||
return _registry
|
||||
|
||||
|
||||
def init_permission_registry(active_plugin_names: set[str] | None = None) -> None:
|
||||
"""Initialize the global permission registry."""
|
||||
_registry.initialize(active_plugin_names)
|
||||
|
||||
|
||||
def register_plugin_permissions(plugin_name: str, permissions: list[str]) -> None:
|
||||
"""Convenience: register plugin permissions on the global registry."""
|
||||
_registry.register_plugin_permissions(plugin_name, permissions)
|
||||
|
||||
|
||||
def unregister_plugin_permissions(plugin_name: str) -> None:
|
||||
"""Convenience: unregister plugin permissions on the global registry."""
|
||||
_registry.unregister_plugin_permissions(plugin_name)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Permission resolver — resolves effective permissions for a user+tenant.
|
||||
|
||||
Architecture:
|
||||
- Permissions are NOT stored in the session.
|
||||
- Redis cache: resolved:{user_id}:{tenant_id} with 5-min TTL.
|
||||
- Permission-version stamping for immediate invalidation.
|
||||
- Resolution: allowed = (role ∪ groups), denied = (role.denied ∪ groups.denied), resolved = allowed − denied.
|
||||
- Wildcards: companies:*, *:read, *:* (bare * is forbidden).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.role import Role
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
CACHE_PREFIX = "resolved"
|
||||
|
||||
|
||||
def _matches_permission(granted: str, required: str) -> bool:
|
||||
"""Check if a granted permission matches the required permission.
|
||||
|
||||
Supports wildcards:
|
||||
- companies:read → exact match
|
||||
- companies:* → all actions for companies
|
||||
- *:read → all modules, read action
|
||||
- *:* → everything (superadmin)
|
||||
"""
|
||||
if granted == required:
|
||||
return True
|
||||
g_parts = granted.split(":")
|
||||
r_parts = required.split(":")
|
||||
# Wildcard * matches any single segment, but remaining segments must still match
|
||||
if len(g_parts) != len(r_parts):
|
||||
return False
|
||||
for i, g_part in enumerate(g_parts):
|
||||
if g_part == "*":
|
||||
continue
|
||||
if g_part != r_parts[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _permission_matches_any(granted_permissions: set[str], required: str) -> bool:
|
||||
"""Check if any granted permission matches the required permission."""
|
||||
for granted in granted_permissions:
|
||||
if _matches_permission(granted, required):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_permissions(permissions: Any) -> set[str]:
|
||||
"""Normalize permissions from JSONB to a set of strings.
|
||||
|
||||
Supports formats:
|
||||
- list[str]: ["companies:read", "contacts:write"]
|
||||
- dict[str, bool]: {"companies:read": true, "contacts:write": false}
|
||||
- dict[str, dict]: {"companies": {"read": true, "write": false}}
|
||||
"""
|
||||
result: set[str] = set()
|
||||
if isinstance(permissions, list):
|
||||
for p in permissions:
|
||||
if isinstance(p, str):
|
||||
result.add(p.replace(".", ":"))
|
||||
elif isinstance(permissions, dict):
|
||||
for key, val in permissions.items():
|
||||
if isinstance(val, bool):
|
||||
if val:
|
||||
result.add(key.replace(".", ":"))
|
||||
elif isinstance(val, dict):
|
||||
for action, enabled in val.items():
|
||||
if enabled:
|
||||
result.add(f"{key}:{action}".replace(".", ":"))
|
||||
return result
|
||||
|
||||
|
||||
async def resolve_permissions(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve effective permissions for a user within a tenant.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"permissions": set[str], # allowed permissions
|
||||
"denied": set[str], # explicitly denied
|
||||
"field_permissions": dict, # {module: {field: hidden|readonly|read}}
|
||||
"is_system_admin": bool,
|
||||
"version": int, # permission_version for cache invalidation
|
||||
}
|
||||
"""
|
||||
# Check system admin first
|
||||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
is_system_admin = user_result.scalar() or False
|
||||
|
||||
if is_system_admin:
|
||||
return {
|
||||
"permissions": {"*:*"},
|
||||
"denied": set(),
|
||||
"field_permissions": {},
|
||||
"is_system_admin": True,
|
||||
"version": 0, # system admin doesn't need version tracking
|
||||
}
|
||||
|
||||
# Load UserTenant to get role_id
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
|
||||
allowed: set[str] = set()
|
||||
denied: set[str] = set()
|
||||
field_perms: dict[str, dict[str, str]] = {}
|
||||
max_version = 0
|
||||
|
||||
# Load role permissions
|
||||
if user_tenant and user_tenant.role_id:
|
||||
role_q = select(Role).where(Role.id == user_tenant.role_id)
|
||||
role_result = await db.execute(role_q)
|
||||
role = role_result.scalar_one_or_none()
|
||||
if role:
|
||||
allowed |= _normalize_permissions(role.permissions)
|
||||
denied |= _normalize_permissions(role.denied_permissions)
|
||||
max_version = max(max_version, role.permission_version)
|
||||
# Merge field permissions
|
||||
if role.field_permissions:
|
||||
for module, fields in role.field_permissions.items():
|
||||
if isinstance(fields, dict):
|
||||
if module not in field_perms:
|
||||
field_perms[module] = {}
|
||||
field_perms[module].update(fields)
|
||||
|
||||
# Also check legacy role string on User for backward compatibility
|
||||
if user_tenant is None or user_tenant.role_id is None:
|
||||
legacy_q = select(User.role).where(User.id == user_id)
|
||||
legacy_result = await db.execute(legacy_q)
|
||||
legacy_role = legacy_result.scalar_one_or_none()
|
||||
if legacy_role == "admin":
|
||||
allowed.add("*:*")
|
||||
elif legacy_role == "editor":
|
||||
allowed |= {"companies:read", "companies:write", "contacts:read", "contacts:write",
|
||||
"users:read", "roles:read", "audit:read", "attachments:read",
|
||||
"attachments:write", "workflows:read", "workflows:write",
|
||||
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
|
||||
"taxes:read", "taxes:write", "currencies:read", "currencies:write",
|
||||
"notifications:read", "notifications:write", "import_export:read",
|
||||
"import_export:write"}
|
||||
elif legacy_role == "viewer":
|
||||
allowed |= {"companies:read", "contacts:read", "users:read", "roles:read",
|
||||
"audit:read", "attachments:read", "workflows:read", "sequences:read",
|
||||
"addresses:read", "taxes:read", "currencies:read",
|
||||
"notifications:read", "import_export:read"}
|
||||
|
||||
# Load group permissions
|
||||
ug_q = select(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
ug_result = await db.execute(ug_q)
|
||||
user_groups = ug_result.scalars().all()
|
||||
|
||||
if user_groups:
|
||||
group_ids = [ug.group_id for ug in user_groups]
|
||||
groups_q = select(Group).where(Group.id.in_(group_ids))
|
||||
groups_result = await db.execute(groups_q)
|
||||
groups = groups_result.scalars().all()
|
||||
|
||||
for group in groups:
|
||||
allowed |= _normalize_permissions(group.permissions)
|
||||
denied |= _normalize_permissions(group.denied_permissions)
|
||||
max_version = max(max_version, group.permission_version)
|
||||
# Merge field permissions
|
||||
if group.field_permissions:
|
||||
for module, fields in group.field_permissions.items():
|
||||
if isinstance(fields, dict):
|
||||
if module not in field_perms:
|
||||
field_perms[module] = {}
|
||||
field_perms[module].update(fields)
|
||||
|
||||
# Apply deny list
|
||||
resolved = allowed - denied
|
||||
|
||||
return {
|
||||
"permissions": resolved,
|
||||
"denied": denied,
|
||||
"field_permissions": field_perms,
|
||||
"is_system_admin": False,
|
||||
"version": max_version,
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_permissions(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Get resolved permissions from Redis cache or resolve from DB."""
|
||||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||||
|
||||
raw = await redis.get(cache_key)
|
||||
if raw is not None:
|
||||
data = json.loads(raw)
|
||||
return data
|
||||
|
||||
# Cache miss — resolve from DB
|
||||
resolved = await resolve_permissions(db, user_id, tenant_id)
|
||||
|
||||
# Store in cache (convert sets to lists for JSON)
|
||||
cache_data = {
|
||||
"permissions": list(resolved["permissions"]),
|
||||
"denied": list(resolved["denied"]),
|
||||
"field_permissions": resolved["field_permissions"],
|
||||
"is_system_admin": resolved["is_system_admin"],
|
||||
"version": resolved["version"],
|
||||
}
|
||||
await redis.setex(cache_key, CACHE_TTL, json.dumps(cache_data))
|
||||
return cache_data
|
||||
|
||||
|
||||
async def invalidate_permission_cache(
|
||||
redis: aioredis.Redis,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Invalidate the permission cache for a specific user+tenant."""
|
||||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||||
await redis.delete(cache_key)
|
||||
|
||||
|
||||
async def invalidate_all_user_permissions(
|
||||
redis: aioredis.Redis,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change)."""
|
||||
pattern = f"{CACHE_PREFIX}:*:{tenant_id}"
|
||||
keys = await redis.keys(pattern)
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
|
||||
|
||||
def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
||||
"""Check if resolved permissions grant the required permission.
|
||||
|
||||
Args:
|
||||
resolved: result from get_cached_permissions or resolve_permissions
|
||||
required: permission string like "companies:read"
|
||||
"""
|
||||
if resolved.get("is_system_admin"):
|
||||
return True
|
||||
|
||||
permissions = set(resolved.get("permissions", []))
|
||||
denied = set(resolved.get("denied", []))
|
||||
|
||||
# Check deny list first
|
||||
for d in denied:
|
||||
if _matches_permission(d, required):
|
||||
return False
|
||||
|
||||
return _permission_matches_any(permissions, required)
|
||||
|
||||
|
||||
def check_field_access(
|
||||
resolved: dict[str, Any],
|
||||
module: str,
|
||||
field: str,
|
||||
default: str = "read",
|
||||
) -> str:
|
||||
"""Check field-level access for a module+field.
|
||||
|
||||
Returns: "hidden", "readonly", or "read"
|
||||
"""
|
||||
if resolved.get("is_system_admin"):
|
||||
return "read"
|
||||
|
||||
field_perms = resolved.get("field_permissions", {})
|
||||
module_perms = field_perms.get(module, {})
|
||||
return module_perms.get(field, default)
|
||||
|
||||
|
||||
def filter_fields_by_permission(
|
||||
data: dict[str, Any],
|
||||
resolved: dict[str, Any],
|
||||
module: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Filter response fields based on field-level permissions.
|
||||
|
||||
Removes fields marked as "hidden", keeps others.
|
||||
"""
|
||||
if resolved.get("is_system_admin"):
|
||||
return data
|
||||
|
||||
field_perms = resolved.get("field_permissions", {})
|
||||
module_perms = field_perms.get(module, {})
|
||||
|
||||
if not module_perms:
|
||||
return data
|
||||
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
perm = module_perms.get(key)
|
||||
if perm == "hidden":
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
Reference in New Issue
Block a user