From b490a62322c7dc47d897d94e5b689ebd89ba6537 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 21:59:45 +0200 Subject: [PATCH] feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention --- alembic/versions/0019_rbac_groups.py | 127 +++++++++++ app/core/auth.py | 1 + app/core/permission_registry.py | 159 +++++++++++++ app/core/permissions.py | 323 +++++++++++++++++++++++++++ app/deps.py | 101 ++++++++- app/main.py | 18 ++ app/models/__init__.py | 3 + app/models/group.py | 63 ++++++ app/models/role.py | 8 +- app/models/user.py | 9 + app/routes/auth.py | 21 ++ app/routes/groups.py | 262 ++++++++++++++++++++++ app/routes/roles.py | 21 ++ app/routes/users.py | 17 ++ app/schemas/group.py | 42 ++++ app/services/auth_service.py | 1 + app/services/group_service.py | 245 ++++++++++++++++++++ 17 files changed, 1408 insertions(+), 13 deletions(-) create mode 100644 alembic/versions/0019_rbac_groups.py create mode 100644 app/core/permission_registry.py create mode 100644 app/core/permissions.py create mode 100644 app/models/group.py create mode 100644 app/routes/groups.py create mode 100644 app/schemas/group.py create mode 100644 app/services/group_service.py diff --git a/alembic/versions/0019_rbac_groups.py b/alembic/versions/0019_rbac_groups.py new file mode 100644 index 0000000..cc32318 --- /dev/null +++ b/alembic/versions/0019_rbac_groups.py @@ -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") diff --git a/app/core/auth.py b/app/core/auth.py index ffdb69c..13d547a 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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, } diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py new file mode 100644 index 0000000..d939b09 --- /dev/null +++ b/app/core/permission_registry.py @@ -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) diff --git a/app/core/permissions.py b/app/core/permissions.py new file mode 100644 index 0000000..9e6be1e --- /dev/null +++ b/app/core/permissions.py @@ -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 diff --git a/app/deps.py b/app/deps.py index 6784349..ac394aa 100644 --- a/app/deps.py +++ b/app/deps.py @@ -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( diff --git a/app/main.py b/app/main.py index 9259c48..f3d73d1 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index f9119ae..168e23c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..c03bd0b --- /dev/null +++ b/app/models/group.py @@ -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() + ) diff --git a/app/models/role.py b/app/models/role.py index 320fbaa..3dc7fbd 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -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() ) diff --git a/app/models/user.py b/app/models/user.py index eab4d50..01ed6c2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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() ) diff --git a/app/routes/auth.py b/app/routes/auth.py index 302ca8e..967a316 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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, diff --git a/app/routes/groups.py b/app/routes/groups.py new file mode 100644 index 0000000..fe84d7d --- /dev/null +++ b/app/routes/groups.py @@ -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} diff --git a/app/routes/roles.py b/app/routes/roles.py index 139118d..ed85bf9 100644 --- a/app/routes/roles.py +++ b/app/routes/roles.py @@ -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) diff --git a/app/routes/users.py b/app/routes/users.py index fd18659..e5b4dd2 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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, diff --git a/app/schemas/group.py b/app/schemas/group.py new file mode 100644 index 0000000..bb053df --- /dev/null +++ b/app/schemas/group.py @@ -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 diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 9faf141..1c34cf5 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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, } diff --git a/app/services/group_service.py b/app/services/group_service.py new file mode 100644 index 0000000..92d5196 --- /dev/null +++ b/app/services/group_service.py @@ -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()