feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""RBAC: groups, user_groups, system_admin, tenant-scoped role_id, denied_permissions.
|
||||
|
||||
Revision ID: 0019_rbac_groups
|
||||
Revises: 0018_fix_notif
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0019_rbac_groups"
|
||||
down_revision = "0018_fix_notif"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── groups table ──
|
||||
op.create_table(
|
||||
"groups",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.String(500), nullable=True),
|
||||
sa.Column("permissions", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("denied_permissions", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("field_permissions", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="uq_groups_tenant_name"),
|
||||
)
|
||||
|
||||
# ── user_groups table ──
|
||||
op.create_table(
|
||||
"user_groups",
|
||||
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("group_id", PGUUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "group_id", "tenant_id", name="uq_user_groups_user_group_tenant"),
|
||||
)
|
||||
|
||||
# ── users: add is_system_admin ──
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_system_admin", sa.Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
# ── user_tenants: add role_id ──
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column("role_id", PGUUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_user_tenants_role_id", "user_tenants", ["role_id"])
|
||||
|
||||
# ── roles: add denied_permissions + permission_version ──
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("denied_permissions", JSONB, nullable=False, server_default="[]"),
|
||||
)
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
# ── Seed default roles per tenant ──
|
||||
# For each tenant, create admin/editor/viewer role records if they don't exist
|
||||
op.execute("""
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
t.id,
|
||||
r.role_name,
|
||||
r.permissions::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
FROM tenants t
|
||||
CROSS JOIN (VALUES
|
||||
('admin', '{"*:*": true}'::json),
|
||||
('editor', '{"core:*:read": true, "core:*:write": true, "core:*:create": true}'::json),
|
||||
('viewer', '{"core:*:read": true}'::json)
|
||||
) AS r(role_name, permissions)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM roles ro
|
||||
WHERE ro.tenant_id = t.id AND ro.name = r.role_name
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Migrate existing user.role_id to user_tenants.role_id ──
|
||||
# For each user_tenants row, set role_id from users table if the user has one
|
||||
op.execute("""
|
||||
UPDATE user_tenants ut
|
||||
SET role_id = u.role_id
|
||||
FROM users u
|
||||
WHERE ut.user_id = u.id
|
||||
AND u.role_id IS NOT NULL
|
||||
AND ut.role_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Migrate legacy role strings to role records ──
|
||||
# For users with role='admin'/'editor'/'viewer' but no role_id on user_tenants,
|
||||
# link to the seeded default roles
|
||||
op.execute("""
|
||||
UPDATE user_tenants ut
|
||||
SET role_id = ro.id
|
||||
FROM users u, roles ro
|
||||
WHERE ut.user_id = u.id
|
||||
AND ro.tenant_id = ut.tenant_id
|
||||
AND ro.name = u.role
|
||||
AND ut.role_id IS NULL
|
||||
AND u.role IN ('admin', 'editor', 'viewer')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("roles", "permission_version")
|
||||
op.drop_column("roles", "denied_permissions")
|
||||
op.drop_index("ix_user_tenants_role_id", table_name="user_tenants")
|
||||
op.drop_column("user_tenants", "role_id")
|
||||
op.drop_column("users", "is_system_admin")
|
||||
op.drop_table("user_groups")
|
||||
op.drop_table("groups")
|
||||
@@ -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
|
||||
+89
-12
@@ -25,7 +25,9 @@ async def get_current_user(
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
) -> dict[str, Any]:
|
||||
"""Get the current authenticated user from session cookie.
|
||||
Returns session data dict with user_id, tenant_id, email, name, role.
|
||||
|
||||
Returns session data dict with user_id, tenant_id, email, name, role,
|
||||
and resolved permissions from Redis cache.
|
||||
"""
|
||||
settings = get_settings()
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
@@ -52,32 +54,107 @@ async def get_current_user(
|
||||
tenant_id = uuid.UUID(session_data["tenant_id"])
|
||||
await set_tenant_context(db, tenant_id)
|
||||
|
||||
# Load resolved permissions from cache (or DB on miss)
|
||||
from app.core.permissions import get_cached_permissions
|
||||
|
||||
user_id = uuid.UUID(session_data["user_id"])
|
||||
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
||||
session_data["permissions"] = resolved.get("permissions", [])
|
||||
session_data["denied_permissions"] = resolved.get("denied", [])
|
||||
session_data["field_permissions"] = resolved.get("field_permissions", {})
|
||||
session_data["is_system_admin"] = resolved.get("is_system_admin", False)
|
||||
|
||||
return session_data
|
||||
|
||||
|
||||
async def require_admin(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require admin role."""
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Admin access required", "code": "forbidden"},
|
||||
)
|
||||
return current_user
|
||||
"""Require admin role (legacy + new permission system)."""
|
||||
if current_user.get("is_system_admin") or current_user.get("role") == "admin":
|
||||
return current_user
|
||||
# Also check via permission system
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, "*:*"):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Admin access required", "code": "forbidden"},
|
||||
)
|
||||
|
||||
|
||||
async def require_write(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require write permission (admin or editor)."""
|
||||
"""Require write permission (admin, editor, or custom role with write perms)."""
|
||||
if current_user.get("is_system_admin"):
|
||||
return current_user
|
||||
role = current_user.get("role", "viewer")
|
||||
if role not in ("admin", "editor"):
|
||||
if role in ("admin", "editor"):
|
||||
return current_user
|
||||
# Check via permission system for custom roles
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, "*:write") or check_permission(current_user, "*:create"):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Write access required", "code": "forbidden"},
|
||||
)
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""FastAPI dependency factory: require a specific permission.
|
||||
|
||||
Usage:
|
||||
@router.get("/companies", dependencies=[Depends(require_permission("companies:read"))])
|
||||
"""
|
||||
async def _check(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if current_user.get("is_system_admin"):
|
||||
return current_user
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
if check_permission(current_user, permission):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Write access required", "code": "forbidden"},
|
||||
detail={
|
||||
"detail": f"Permission '{permission}' required",
|
||||
"code": "forbidden",
|
||||
},
|
||||
)
|
||||
return current_user
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
def require_field_access(module: str, field: str, default: str = "read"):
|
||||
"""FastAPI dependency factory: require field-level access.
|
||||
|
||||
Usage:
|
||||
@router.get("/contacts/{id}", dependencies=[Depends(require_field_access("contacts", "annual_revenue"))])
|
||||
"""
|
||||
async def _check(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if current_user.get("is_system_admin"):
|
||||
return current_user
|
||||
from app.core.permissions import check_field_access
|
||||
|
||||
access = check_field_access(current_user, module, field, default)
|
||||
if access == "hidden":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"detail": f"Field '{field}' is hidden",
|
||||
"code": "field_hidden",
|
||||
},
|
||||
)
|
||||
return current_user
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
async def get_tenant_id(
|
||||
|
||||
+18
@@ -30,6 +30,7 @@ from app.routes import (
|
||||
auth,
|
||||
companies,
|
||||
contacts,
|
||||
groups,
|
||||
health,
|
||||
import_export,
|
||||
metrics,
|
||||
@@ -160,6 +161,22 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Initialize permission registry with active plugin names
|
||||
from app.core.permission_registry import init_permission_registry, register_plugin_permissions
|
||||
active_plugin_names: set[str] = set()
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
sa_select(PluginModel).where(PluginModel.active == True) # noqa: E712
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
active_plugin_names.add(record.name)
|
||||
plugin = registry.get_plugin(record.name)
|
||||
if plugin and plugin.manifest.permissions:
|
||||
register_plugin_permissions(record.name, plugin.manifest.permissions)
|
||||
|
||||
init_permission_registry(active_plugin_names)
|
||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
|
||||
from app.core.seeds import seed_default_data
|
||||
|
||||
@@ -198,6 +215,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(roles.router)
|
||||
app.include_router(groups.router)
|
||||
app.include_router(tenants.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(companies.router)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.company import Company
|
||||
from app.models.contact import CompanyContact, Contact
|
||||
from app.models.currency import Currency
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||
from app.models.plugin import Plugin, PluginMigration
|
||||
from app.models.role import Role
|
||||
@@ -24,6 +25,8 @@ __all__ = [
|
||||
"User",
|
||||
"UserTenant",
|
||||
"Role",
|
||||
"Group",
|
||||
"UserGroup",
|
||||
"Session",
|
||||
"AuditLog",
|
||||
"DeletionLog",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Group and UserGroup models for granular RBAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Group(Base, TenantMixin):
|
||||
"""Group entity with RBAC permissions and field-level permissions.
|
||||
|
||||
Groups are tenant-scoped. Users can be members of multiple groups.
|
||||
Effective permissions = (role.permissions ∪ Σ(group.permissions)) − denied.
|
||||
"""
|
||||
|
||||
__tablename__ = "groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_groups_tenant_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=True, default=None)
|
||||
permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
denied_permissions: Mapped[list[str]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
field_permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
permission_version: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=1, server_default="1"
|
||||
)
|
||||
|
||||
|
||||
class UserGroup(Base):
|
||||
"""N:M association — user membership in groups (per tenant)."""
|
||||
|
||||
__tablename__ = "user_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "group_id", "tenant_id", name="uq_user_groups_user_group_tenant"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
+7
-1
@@ -6,7 +6,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -24,7 +24,13 @@ class Role(Base, TenantMixin):
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
denied_permissions: Mapped[list[str]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
field_permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
permission_version: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=1, server_default="1"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -35,6 +35,9 @@ class User(Base, TenantMixin):
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
is_system_admin: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, server_default="false"
|
||||
)
|
||||
|
||||
|
||||
class UserTenant(Base):
|
||||
@@ -49,6 +52,12 @@ class UserTenant(Base):
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.schemas.auth import (
|
||||
PasswordResetRequest,
|
||||
SwitchTenantRequest,
|
||||
)
|
||||
from app.deps import get_current_user
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
@@ -72,6 +73,7 @@ async def login(
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.name,
|
||||
"csrf_token": csrf_token,
|
||||
@@ -134,6 +136,25 @@ async def me(
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/me/permissions")
|
||||
async def me_permissions(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get resolved permissions for the current user.
|
||||
|
||||
Returns permissions, denied_permissions, field_permissions, and is_system_admin.
|
||||
Used by frontend to enable/disable UI elements based on permissions.
|
||||
"""
|
||||
return {
|
||||
"permissions": current_user.get("permissions", []),
|
||||
"denied_permissions": current_user.get("denied_permissions", []),
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
"is_system_admin": current_user.get("is_system_admin", False),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/switch-tenant")
|
||||
async def switch_tenant(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Group management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.permissions import invalidate_all_user_permissions
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.schemas.group import AddMemberRequest, GroupCreate, GroupUpdate
|
||||
from app.services.group_service import group_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/groups", tags=["groups"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all groups in the current tenant."""
|
||||
import uuid as _uuid
|
||||
|
||||
tenant_id = _uuid.UUID(current_user["tenant_id"])
|
||||
return {"items": await group_service.list_groups(db, tenant_id)}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(
|
||||
body: GroupCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Create a new group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
group = await group_service.create_group(
|
||||
db,
|
||||
tenant_id,
|
||||
body.name,
|
||||
body.description,
|
||||
body.permissions,
|
||||
body.denied_permissions,
|
||||
body.field_permissions,
|
||||
)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "create", "group", group.id,
|
||||
changes={"name": body.name},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"denied_permissions": group.denied_permissions,
|
||||
"field_permissions": group.field_permissions,
|
||||
"permission_version": group.permission_version,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{group_id}")
|
||||
async def get_group(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
group = await group_service.get_group(db, tenant_id, gid)
|
||||
if group is None:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"denied_permissions": group.denied_permissions,
|
||||
"field_permissions": group.field_permissions,
|
||||
"permission_version": group.permission_version,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{group_id}")
|
||||
async def update_group(
|
||||
group_id: str,
|
||||
body: GroupUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Update a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
group = await group_service.update_group(
|
||||
db, tenant_id, gid,
|
||||
body.name, body.description,
|
||||
body.permissions, body.denied_permissions, body.field_permissions,
|
||||
)
|
||||
if group is None:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "update", "group", gid,
|
||||
changes=body.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"denied_permissions": group.denied_permissions,
|
||||
"field_permissions": group.field_permissions,
|
||||
"permission_version": group.permission_version,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:delete")),
|
||||
):
|
||||
"""Delete a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.delete_group(db, tenant_id, gid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "delete", "group", gid,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}/members")
|
||||
async def list_group_members(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
members = await group_service.get_group_members(db, tenant_id, gid)
|
||||
return {"items": members}
|
||||
|
||||
|
||||
@router.post("/{group_id}/members")
|
||||
async def add_group_member(
|
||||
group_id: str,
|
||||
body: AddMemberRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Add a user to a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
uid = uuid.UUID(body.user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.add_user_to_group(db, tenant_id, gid, uid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Group or user not found in tenant", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for this user
|
||||
redis = get_redis()
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "add_member", "group", gid,
|
||||
changes={"user_id": body.user_id},
|
||||
)
|
||||
|
||||
return {"message": "User added to group"}
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_group_member(
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Remove a user from a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.remove_user_from_group(db, tenant_id, gid, uid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Membership not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for this user
|
||||
redis = get_redis()
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "remove_member", "group", gid,
|
||||
changes={"user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user/{user_id}")
|
||||
async def get_user_groups(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all groups a user is member of."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
|
||||
|
||||
groups = await group_service.get_user_groups(db, tenant_id, uid)
|
||||
return {"items": groups}
|
||||
@@ -9,7 +9,10 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
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.permissions import invalidate_all_user_permissions
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from app.plugins.registry import get_registry
|
||||
@@ -142,6 +145,17 @@ async def update_role(
|
||||
if role is None:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
# Audit log
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "update", "role", rid,
|
||||
changes=body.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(role.id),
|
||||
"name": role.name,
|
||||
@@ -169,4 +183,11 @@ async def delete_role(
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
await log_audit(db, tenant_id, acting_user_id, "delete", "role", rid)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -9,8 +9,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
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.notifications import create_notification
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.services.user_service import user_service, _UNSET
|
||||
@@ -143,6 +145,9 @@ async def update_user(
|
||||
Uses ``model_fields_set`` to detect whether ``role_id`` was explicitly
|
||||
present in the request body (even if sent as ``null``). This allows
|
||||
the caller to clear the FK by sending ``role_id: null``.
|
||||
|
||||
Self-modification prevention: a user cannot change their own role,
|
||||
is_active status, or system_admin flag.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
@@ -153,6 +158,14 @@ async def update_user(
|
||||
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
# Self-modification prevention: cannot change own role or active status
|
||||
if uid == acting_user_id:
|
||||
if body.role is not None or body.is_active is not None or "role_id" in body.model_fields_set:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Cannot modify your own role or active status", "code": "self_modification_forbidden"},
|
||||
)
|
||||
|
||||
# Determine if role_id was explicitly sent (Pydantic v2)
|
||||
role_id_sent = "role_id" in body.model_fields_set
|
||||
|
||||
@@ -188,6 +201,10 @@ async def update_user(
|
||||
|
||||
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
|
||||
|
||||
# Invalidate permission cache for the updated user
|
||||
redis = get_redis()
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Group schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
permissions: dict = Field(default_factory=dict)
|
||||
denied_permissions: list[str] = Field(default_factory=list)
|
||||
field_permissions: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
description: str | None = Field(None, max_length=500)
|
||||
permissions: dict | None = None
|
||||
denied_permissions: list[str] | None = None
|
||||
field_permissions: dict | None = None
|
||||
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
permissions: dict
|
||||
denied_permissions: list[str]
|
||||
field_permissions: dict
|
||||
permission_version: int
|
||||
|
||||
|
||||
class GroupMemberResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
name: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
@@ -116,6 +116,7 @@ class AuthService:
|
||||
"email": session_data["email"],
|
||||
"name": session_data["name"],
|
||||
"role": session_data["role"],
|
||||
"is_system_admin": session_data.get("is_system_admin", False),
|
||||
"tenant_id": session_data["tenant_id"],
|
||||
"tenant_name": tenant.name if tenant else None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Group management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
class GroupService:
|
||||
"""Handles group CRUD and user-group membership."""
|
||||
|
||||
async def list_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all groups in a tenant."""
|
||||
q = select(Group).where(Group.tenant_id == tenant_id, Group.deleted_at.is_(None))
|
||||
result = await db.execute(q)
|
||||
groups = result.scalars().all()
|
||||
return [self._group_to_dict(g) for g in groups]
|
||||
|
||||
async def get_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> Group | None:
|
||||
"""Get a single group by ID within tenant scope."""
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
denied_permissions: list[str] | None = None,
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Group:
|
||||
"""Create a new group."""
|
||||
group = Group(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
permissions=permissions or {},
|
||||
denied_permissions=denied_permissions or [],
|
||||
field_permissions=field_permissions or {},
|
||||
)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
return group
|
||||
|
||||
async def update_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
denied_permissions: list[str] | None = None,
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Group | None:
|
||||
"""Update a group. Increments permission_version on permission changes."""
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
return None
|
||||
|
||||
version_bump = False
|
||||
if name is not None:
|
||||
group.name = name
|
||||
if description is not None:
|
||||
group.description = description
|
||||
if permissions is not None:
|
||||
group.permissions = permissions
|
||||
version_bump = True
|
||||
if denied_permissions is not None:
|
||||
group.denied_permissions = denied_permissions
|
||||
version_bump = True
|
||||
if field_permissions is not None:
|
||||
group.field_permissions = field_permissions
|
||||
version_bump = True
|
||||
|
||||
if version_bump:
|
||||
group.permission_version += 1
|
||||
|
||||
await db.flush()
|
||||
return group
|
||||
|
||||
async def delete_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Soft-delete a group."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
return False
|
||||
|
||||
group.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
async def get_group_members(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all users in a group."""
|
||||
q = (
|
||||
select(User, UserGroup)
|
||||
.join(User, UserGroup.user_id == User.id)
|
||||
.where(
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return [
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user, _ug in result.all()
|
||||
]
|
||||
|
||||
async def add_user_to_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Add a user to a group."""
|
||||
# Verify user is in this tenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
if ut_result.scalar_one_or_none() is None:
|
||||
return False
|
||||
|
||||
# Check if already member
|
||||
existing_q = select(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
existing_result = await db.execute(existing_q)
|
||||
if existing_result.scalar_one_or_none() is not None:
|
||||
return True # Already member
|
||||
|
||||
ug = UserGroup(
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(ug)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
async def remove_user_from_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Remove a user from a group."""
|
||||
q = delete(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
await db.flush()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def get_user_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all groups a user is member of."""
|
||||
q = (
|
||||
select(Group)
|
||||
.join(UserGroup, UserGroup.group_id == Group.id)
|
||||
.where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return [self._group_to_dict(g) for g in result.scalars().all()]
|
||||
|
||||
def _group_to_dict(self, group: Group) -> dict[str, Any]:
|
||||
"""Convert group to response dict."""
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"denied_permissions": group.denied_permissions,
|
||||
"field_permissions": group.field_permissions,
|
||||
"permission_version": group.permission_version,
|
||||
}
|
||||
|
||||
|
||||
group_service = GroupService()
|
||||
Reference in New Issue
Block a user