feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention

This commit is contained in:
Agent Zero
2026-07-15 21:59:45 +02:00
parent 905bc9b744
commit b490a62322
17 changed files with 1408 additions and 13 deletions
+127
View File
@@ -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")
+1
View File
@@ -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,
}
+159
View File
@@ -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)
+323
View File
@@ -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
+83 -6
View File
@@ -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":
"""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"},
)
return current_user
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": f"Permission '{permission}' required",
"code": "forbidden",
},
)
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
View File
@@ -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)
+3
View File
@@ -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",
+63
View File
@@ -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
View File
@@ -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()
)
+9
View File
@@ -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()
)
+21
View File
@@ -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,
+262
View File
@@ -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}
+21
View File
@@ -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)
+17
View File
@@ -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,
+42
View File
@@ -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
+1
View File
@@ -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,
}
+245
View File
@@ -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()