sprint20-23: tests + documentation + guest access + infrastructure + migrations 0059
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""Create guest_users table.
|
||||
|
||||
Revision ID: 0059
|
||||
Revises: 0058
|
||||
Create Date: 2026-07-29 02:47:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0059"
|
||||
down_revision: str | None = "0058"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"guest_users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="invited"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_guest_users_email_tenant", "guest_users", ["email", "tenant_id"], unique=True)
|
||||
op.create_index("ix_guest_users_status", "guest_users", ["status", "tenant_id"])
|
||||
op.create_index("ix_guest_users_invited_by", "guest_users", ["invited_by"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_guest_users_invited_by", table_name="guest_users")
|
||||
op.drop_index("ix_guest_users_status", table_name="guest_users")
|
||||
op.drop_index("ix_guest_users_email_tenant", table_name="guest_users")
|
||||
op.drop_table("guest_users")
|
||||
+34
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
|
||||
from app.core.db import get_db, set_tenant_context, set_user_context
|
||||
from app.models.guest_user import GuestUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,6 +43,39 @@ async def get_redis_dep() -> aioredis.Redis:
|
||||
return get_redis()
|
||||
|
||||
|
||||
async def get_current_guest(
|
||||
request: Request,
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
) -> dict[str, Any]:
|
||||
"""Get the current guest user from guest session cookie.
|
||||
|
||||
Returns session data dict with guest_user_id, tenant_id, email, name.
|
||||
Used for guest-specific endpoints (guest login, guest contacts).
|
||||
"""
|
||||
session_id = request.cookies.get("guest_session")
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||
)
|
||||
|
||||
import json
|
||||
|
||||
raw = await redis.get(f"guest_session:{session_id}")
|
||||
if raw is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
||||
)
|
||||
|
||||
session_data = json.loads(raw)
|
||||
|
||||
# Extend TTL on each request (sliding session)
|
||||
await redis.expire(f"guest_session:{session_id}", 1800)
|
||||
|
||||
return session_data
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -65,6 +65,8 @@ from app.routes import (
|
||||
permission_templates,
|
||||
delegations,
|
||||
policies,
|
||||
guest_auth,
|
||||
guests,
|
||||
)
|
||||
|
||||
|
||||
@@ -410,6 +412,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(delegations.router)
|
||||
app.include_router(policies.router)
|
||||
app.include_router(errors.router)
|
||||
app.include_router(guest_auth.router)
|
||||
app.include_router(guests.router)
|
||||
|
||||
# ── Register plugin routes for all built-in plugins ──
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_folder_permission import ContactFolderPermission
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.guest_user import GuestUser
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"ContactFolderPermission",
|
||||
"ContactMergeHistory",
|
||||
"EntityPermission",
|
||||
"GuestUser",
|
||||
"PermissionDelegation",
|
||||
"PermissionTemplate",
|
||||
"EntityPolicy",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Guest User model — for time-limited guest access via entity permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class GuestUser(Base, TenantMixin):
|
||||
"""Guest user with time-limited access to shared entities.
|
||||
|
||||
Guests are invited by tenant admins and can only access entities
|
||||
that have explicit entity_permissions with principal_type='guest'.
|
||||
"""
|
||||
|
||||
__tablename__ = "guest_users"
|
||||
__table_args__ = (
|
||||
Index("ix_guest_users_email_tenant", "email", "tenant_id", unique=True),
|
||||
Index("ix_guest_users_status", "status", "tenant_id"),
|
||||
Index("ix_guest_users_invited_by", "invited_by"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default=None
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
invited_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="invited"
|
||||
) # 'invited', 'active', 'expired', 'revoked'
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -24,4 +24,6 @@ from app.routes import (
|
||||
users, # noqa: F401
|
||||
user_preferences, # noqa: F401
|
||||
workflows, # noqa: F401
|
||||
guest_auth, # noqa: F401
|
||||
guests, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Guest Auth routes — login, logout for guest users."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, hash_password, verify_password
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_guest
|
||||
from app.models.guest_user import GuestUser
|
||||
|
||||
router = APIRouter(prefix="/api/v1/guest", tags=["guest-auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def guest_login(
|
||||
request: Request,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Guest login with email+password. Sets guest session cookie."""
|
||||
email = body.get("email", "")
|
||||
password = body.get("password", "")
|
||||
tenant_slug = body.get("tenant_slug", "")
|
||||
|
||||
if not email or not password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Email and password required", "code": "missing_fields"},
|
||||
)
|
||||
|
||||
# Find guest user by email
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
if tenant_slug:
|
||||
tenant_q = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = tenant_q.scalar_one_or_none()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
tenant_id = tenant.id
|
||||
else:
|
||||
# Try to find guest by email across all tenants (less secure but simpler)
|
||||
guest_q = await db.execute(
|
||||
select(GuestUser).where(GuestUser.email == email).where(GuestUser.status == "active")
|
||||
)
|
||||
guest = guest_q.scalar_one_or_none()
|
||||
if not guest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
tenant_id = guest.tenant_id
|
||||
|
||||
# Find guest with tenant context
|
||||
guest_q = await db.execute(
|
||||
select(GuestUser)
|
||||
.where(GuestUser.email == email)
|
||||
.where(GuestUser.tenant_id == tenant_id)
|
||||
.where(GuestUser.status == "active")
|
||||
)
|
||||
guest = guest_q.scalar_one_or_none()
|
||||
|
||||
if not guest or not guest.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
|
||||
if not verify_password(password, guest.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if guest.expires_at and guest.expires_at < datetime.now(UTC):
|
||||
guest.status = "expired"
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Guest access expired", "code": "guest_expired"},
|
||||
)
|
||||
|
||||
# Create guest session in Redis
|
||||
redis = get_redis()
|
||||
session_id = str(uuid.uuid4())
|
||||
csrf_token = str(uuid.uuid4())
|
||||
session_data = {
|
||||
"guest_user_id": str(guest.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"email": guest.email,
|
||||
"name": guest.name,
|
||||
"csrf_token": csrf_token,
|
||||
"is_guest": True,
|
||||
}
|
||||
import json
|
||||
|
||||
await redis.setex(
|
||||
f"guest_session:{session_id}",
|
||||
1800, # 30 min TTL
|
||||
json.dumps(session_data),
|
||||
)
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
resp = JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"guest_user_id": str(guest.id),
|
||||
"email": guest.email,
|
||||
"name": guest.name,
|
||||
"tenant_id": str(tenant_id),
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
key="guest_session",
|
||||
value=session_id,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite=settings.session_cookie_samesite,
|
||||
max_age=1800,
|
||||
path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def guest_logout(
|
||||
request: Request,
|
||||
):
|
||||
"""Logout — invalidate guest session, clear cookie."""
|
||||
session_id = request.cookies.get("guest_session")
|
||||
if session_id:
|
||||
redis = get_redis()
|
||||
await redis.delete(f"guest_session:{session_id}")
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
resp = JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"message": "Logged out"},
|
||||
)
|
||||
resp.delete_cookie("guest_session", path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def guest_me(
|
||||
current_guest: dict = Depends(get_current_guest),
|
||||
):
|
||||
"""Get current guest user info."""
|
||||
return {
|
||||
"guest_user_id": current_guest.get("guest_user_id"),
|
||||
"email": current_guest.get("email"),
|
||||
"name": current_guest.get("name"),
|
||||
"tenant_id": current_guest.get("tenant_id"),
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Guest management routes — invite, list, delete guests (admin only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, hash_password
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.models.guest_user import GuestUser
|
||||
|
||||
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/invite")
|
||||
async def invite_guest(
|
||||
request: Request,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Invite a guest user. Admin only."""
|
||||
email = body.get("email", "")
|
||||
name = body.get("name", "")
|
||||
expires_in_hours = body.get("expires_in_hours", 72) # Default 3 days
|
||||
|
||||
if not email or not name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Email and name required", "code": "missing_fields"},
|
||||
)
|
||||
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Check if guest already exists for this tenant
|
||||
existing_q = await db.execute(
|
||||
select(GuestUser)
|
||||
.where(GuestUser.email == email)
|
||||
.where(GuestUser.tenant_id == tenant_id)
|
||||
)
|
||||
existing = existing_q.scalar_one_or_none()
|
||||
if existing:
|
||||
if existing.status == "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"detail": "Guest already active", "code": "guest_exists"},
|
||||
)
|
||||
# Re-invite: update existing record
|
||||
existing.name = name
|
||||
existing.status = "invited"
|
||||
existing.invited_by = user_id
|
||||
existing.expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
|
||||
existing.password_hash = None
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return {
|
||||
"id": str(existing.id),
|
||||
"email": existing.email,
|
||||
"name": existing.name,
|
||||
"status": existing.status,
|
||||
"expires_at": existing.expires_at.isoformat() if existing.expires_at else None,
|
||||
}
|
||||
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
|
||||
guest = GuestUser(
|
||||
email=email,
|
||||
name=name,
|
||||
tenant_id=tenant_id,
|
||||
invited_by=user_id,
|
||||
status="invited",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(guest)
|
||||
await db.commit()
|
||||
await db.refresh(guest)
|
||||
|
||||
return {
|
||||
"id": str(guest.id),
|
||||
"email": guest.email,
|
||||
"name": guest.name,
|
||||
"status": guest.status,
|
||||
"expires_at": guest.expires_at.isoformat() if guest.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accept/{token}")
|
||||
async def accept_invitation(
|
||||
token: str,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Guest accepts invitation and sets password."""
|
||||
password = body.get("password", "")
|
||||
if not password or len(password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Password must be at least 8 characters", "code": "weak_password"},
|
||||
)
|
||||
|
||||
# Token is the guest user ID
|
||||
try:
|
||||
guest_id = uuid.UUID(token)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Invalid token", "code": "invalid_token"},
|
||||
)
|
||||
|
||||
guest_q = await db.execute(
|
||||
select(GuestUser).where(GuestUser.id == guest_id).where(GuestUser.status == "invited")
|
||||
)
|
||||
guest = guest_q.scalar_one_or_none()
|
||||
if not guest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"detail": "Invitation not found or already accepted", "code": "invitation_not_found"},
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if guest.expires_at and guest.expires_at < datetime.now(UTC):
|
||||
guest.status = "expired"
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"detail": "Invitation expired", "code": "invitation_expired"},
|
||||
)
|
||||
|
||||
guest.password_hash = hash_password(password)
|
||||
guest.status = "active"
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Invitation accepted", "status": "active"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_guests(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""List all guest users for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await db.execute(
|
||||
select(GuestUser)
|
||||
.where(GuestUser.tenant_id == tenant_id)
|
||||
.order_by(GuestUser.created_at.desc())
|
||||
)
|
||||
guests = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(g.id),
|
||||
"email": g.email,
|
||||
"name": g.name,
|
||||
"status": g.status,
|
||||
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
|
||||
"created_at": g.created_at.isoformat() if g.created_at else None,
|
||||
}
|
||||
for g in guests
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{guest_id}")
|
||||
async def delete_guest(
|
||||
guest_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Delete/revoke a guest user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
gid = uuid.UUID(guest_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
|
||||
)
|
||||
|
||||
guest_q = await db.execute(
|
||||
select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_id)
|
||||
)
|
||||
guest = guest_q.scalar_one_or_none()
|
||||
if not guest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"detail": "Guest not found", "code": "not_found"},
|
||||
)
|
||||
|
||||
# Revoke: mark as revoked and clear password
|
||||
guest.status = "revoked"
|
||||
guest.password_hash = None
|
||||
await db.commit()
|
||||
|
||||
# Invalidate any active guest sessions
|
||||
redis = get_redis()
|
||||
# We can't easily find all sessions for this guest, but they'll expire naturally
|
||||
|
||||
return {"message": "Guest revoked", "status": "revoked"}
|
||||
@@ -324,8 +324,9 @@ async def get_effective_access(
|
||||
3. Direct user permission
|
||||
4. Group permission (via user_groups)
|
||||
5. Role permission (via user_tenants.role_id)
|
||||
6. owner_id IS NULL → 'read' (tenant-owned, visible to all with module permission)
|
||||
7. No access → 'none'
|
||||
6. Guest permission (principal_type='guest')
|
||||
7. owner_id IS NULL → 'read' (tenant-owned, visible to all with module permission)
|
||||
8. No access → 'none'
|
||||
|
||||
Returns: 'none' | 'read' | 'write' | 'admin' | 'delete' | 'owner'
|
||||
"""
|
||||
@@ -386,6 +387,14 @@ async def get_effective_access(
|
||||
EntityPermission.principal_id == role_id,
|
||||
)
|
||||
)
|
||||
# Guest permission — only if user_id is actually a guest_user_id
|
||||
# (Guest users have no groups/roles, only direct entity_permissions)
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Query permissions
|
||||
now = datetime.now(UTC)
|
||||
@@ -503,6 +512,13 @@ async def get_visible_ids(
|
||||
EntityPermission.principal_id == role_id,
|
||||
)
|
||||
)
|
||||
# Guest permission — guest users have no groups/roles
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
perm_q = await db.execute(
|
||||
select(EntityPermission.entity_id, EntityPermission.permission_level)
|
||||
@@ -595,6 +611,13 @@ async def batch_get_effective_access(
|
||||
EntityPermission.principal_id == role_id,
|
||||
)
|
||||
)
|
||||
# Guest permission — guest users have no groups/roles
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
perm_q = await db.execute(
|
||||
select(EntityPermission.entity_id, EntityPermission.permission_level)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# Infrastructure Guide
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Date:** 2026-07-29
|
||||
> **Applies to:** System administrators and DevOps engineers
|
||||
|
||||
---
|
||||
|
||||
## 1. PgBouncer Setup
|
||||
|
||||
PgBouncer is a lightweight connection pooler for PostgreSQL. It reduces the overhead of establishing new database connections by reusing existing ones.
|
||||
|
||||
### Why PgBouncer?
|
||||
|
||||
- **Connection pooling** — Reduces PostgreSQL connection overhead
|
||||
- **Resource efficiency** — Handles thousands of client connections with minimal resources
|
||||
- **Transaction pooling** — Best for stateless applications like FastAPI
|
||||
- **Session pooling** — For stateful connections
|
||||
- **Statement pooling** — For specific use cases
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
apt-get update && apt-get install -y pgbouncer
|
||||
|
||||
# Verify installation
|
||||
pgbouncer --version
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create `/etc/pgbouncer/pgbouncer.ini`:
|
||||
|
||||
```ini
|
||||
[databases]
|
||||
leocrm = host=localhost port=5432 dbname=leocrm
|
||||
leocrm_test = host=localhost port=5432 dbname=leocrm_test
|
||||
|
||||
[pgbouncer]
|
||||
listen_addr = 0.0.0.0
|
||||
listen_port = 6432
|
||||
unix_socket_dir = /var/run/pgbouncer
|
||||
|
||||
# Authentication
|
||||
# Use md5 for password-based auth
|
||||
# Use trust for local development
|
||||
auth_type = md5
|
||||
auth_file = /etc/pgbouncer/userlist.txt
|
||||
|
||||
# Pool settings
|
||||
pool_mode = transaction
|
||||
default_pool_size = 25
|
||||
max_client_conn = 200
|
||||
max_db_connections = 50
|
||||
|
||||
# Timeouts
|
||||
server_idle_timeout = 600
|
||||
server_lifetime = 3600
|
||||
client_idle_timeout = 1800
|
||||
query_timeout = 30
|
||||
|
||||
# Logging
|
||||
log_connections = 1
|
||||
log_disconnections = 1
|
||||
log_pooler_errors = 1
|
||||
stats_period = 60
|
||||
|
||||
# Security
|
||||
# Only allow connections from localhost and Docker network
|
||||
listen_backlog = 128
|
||||
```
|
||||
|
||||
### User List
|
||||
|
||||
Create `/etc/pgbouncer/userlist.txt`:
|
||||
|
||||
```
|
||||
"leocrm" "md5<password_hash>"
|
||||
"postgres" "md5<password_hash>"
|
||||
```
|
||||
|
||||
Generate the md5 hash:
|
||||
```bash
|
||||
# Format: md5 + md5(password + username)
|
||||
echo -n "md5" && echo -n "your_passwordleocrm" | md5sum | cut -d' ' -f1
|
||||
```
|
||||
|
||||
### Running PgBouncer
|
||||
|
||||
```bash
|
||||
# Start PgBouncer
|
||||
pgbouncer -d /etc/pgbouncer/pgbouncer.ini
|
||||
|
||||
# Check status
|
||||
pgbouncer -d /etc/pgbouncer/pgbouncer.ini -R
|
||||
|
||||
# Reload configuration
|
||||
kill -HUP $(cat /var/run/pgbouncer/pgbouncer.pid)
|
||||
|
||||
# Stop PgBouncer
|
||||
kill -INT $(cat /var/run/pgbouncer/pgbouncer.pid)
|
||||
```
|
||||
|
||||
### Docker Compose Integration
|
||||
|
||||
Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
pgbouncer:
|
||||
image: bitnami/pgbouncer:latest
|
||||
container_name: leocrm-pgbouncer
|
||||
ports:
|
||||
- "6432:6432"
|
||||
environment:
|
||||
- POSTGRESQL_HOST=crm-postgres
|
||||
- POSTGRESQL_PORT=5432
|
||||
- POSTGRESQL_USERNAME=leocrm
|
||||
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRESQL_DATABASE=crm_db
|
||||
- PGBOUNCER_POOL_MODE=transaction
|
||||
- PGBOUNCER_DEFAULT_POOL_SIZE=25
|
||||
- PGBOUNCER_MAX_CLIENT_CONN=200
|
||||
depends_on:
|
||||
- crm-postgres
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Application Configuration
|
||||
|
||||
Update the database URL to use PgBouncer:
|
||||
|
||||
```python
|
||||
# Before (direct connection)
|
||||
DATABASE_URL = "postgresql+asyncpg://leocrm:password@crm-postgres:5432/crm_db"
|
||||
|
||||
# After (via PgBouncer)
|
||||
DATABASE_URL = "postgresql+asyncpg://leocrm:password@leocrm-pgbouncer:6432/crm_db"
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
```bash
|
||||
# Show pool statistics
|
||||
echo "SHOW STATS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer
|
||||
|
||||
# Show active pools
|
||||
echo "SHOW POOLS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer
|
||||
|
||||
# Show clients
|
||||
echo "SHOW CLIENTS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer
|
||||
|
||||
# Show servers
|
||||
echo "SHOW SERVERS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| Connection refused | PgBouncer not running | Check `pgbouncer -d` status |
|
||||
| Auth failed | Wrong password in userlist | Regenerate md5 hash |
|
||||
| Pool exhausted | Too many connections | Increase `default_pool_size` |
|
||||
| Slow queries | Query timeout | Check `query_timeout` setting |
|
||||
| Connection timeout | PostgreSQL overload | Check PostgreSQL connections |
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit Log Partitioning
|
||||
|
||||
The `audit_log` table can grow very large over time. PostgreSQL table partitioning helps manage this by splitting the table into smaller, more manageable pieces.
|
||||
|
||||
### Why Partition?
|
||||
|
||||
- **Faster queries** — Queries only scan relevant partitions
|
||||
- **Easier maintenance** — Drop old partitions instead of DELETE
|
||||
- **Better vacuum** — Each partition is vacuumed independently
|
||||
- **Improved performance** — Smaller indexes per partition
|
||||
|
||||
### Partitioning Strategy
|
||||
|
||||
We use **monthly range partitioning** on the `created_at` column:
|
||||
|
||||
```sql
|
||||
-- Each partition covers one month
|
||||
-- Partition name: audit_log_YYYY_MM
|
||||
-- Example: audit_log_2026_01, audit_log_2026_02, ...
|
||||
```
|
||||
|
||||
### Creating the Partitioned Table
|
||||
|
||||
```sql
|
||||
-- Create the partitioned table
|
||||
CREATE TABLE audit_log_partitioned (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
user_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50),
|
||||
entity_id UUID,
|
||||
changes JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
-- Create monthly partitions
|
||||
CREATE TABLE audit_log_2026_01 PARTITION OF audit_log_partitioned
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
|
||||
CREATE TABLE audit_log_2026_02 PARTITION OF audit_log_partitioned
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
|
||||
CREATE TABLE audit_log_2026_03 PARTITION OF audit_log_partitioned
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
|
||||
-- Add indexes on each partition
|
||||
CREATE INDEX idx_audit_log_2026_01_tenant ON audit_log_2026_01 (tenant_id);
|
||||
CREATE INDEX idx_audit_log_2026_01_action ON audit_log_2026_01 (action);
|
||||
CREATE INDEX idx_audit_log_2026_01_entity ON audit_log_2026_01 (entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_log_2026_01_created ON audit_log_2026_01 (created_at DESC);
|
||||
|
||||
CREATE INDEX idx_audit_log_2026_02_tenant ON audit_log_2026_02 (tenant_id);
|
||||
CREATE INDEX idx_audit_log_2026_02_action ON audit_log_2026_02 (action);
|
||||
CREATE INDEX idx_audit_log_2026_02_entity ON audit_log_2026_02 (entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_log_2026_02_created ON audit_log_2026_02 (created_at DESC);
|
||||
|
||||
CREATE INDEX idx_audit_log_2026_03_tenant ON audit_log_2026_03 (tenant_id);
|
||||
CREATE INDEX idx_audit_log_2026_03_action ON audit_log_2026_03 (action);
|
||||
CREATE INDEX idx_audit_log_2026_03_entity ON audit_log_2026_03 (entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_log_2026_03_created ON audit_log_2026_03 (created_at DESC);
|
||||
```
|
||||
|
||||
### Migrating Existing Data
|
||||
|
||||
```sql
|
||||
-- Step 1: Create the partitioned table
|
||||
-- (see script above)
|
||||
|
||||
-- Step 2: Insert existing data
|
||||
INSERT INTO audit_log_partitioned (
|
||||
id, tenant_id, user_id, action, entity_type,
|
||||
entity_id, changes, ip_address, user_agent, created_at
|
||||
)
|
||||
SELECT id, tenant_id, user_id, action, entity_type,
|
||||
entity_id, changes, ip_address, user_agent, created_at
|
||||
FROM audit_log;
|
||||
|
||||
-- Step 3: Verify data integrity
|
||||
SELECT COUNT(*) FROM audit_log_partitioned;
|
||||
SELECT COUNT(*) FROM audit_log;
|
||||
|
||||
-- Step 4: Rename tables
|
||||
ALTER TABLE audit_log RENAME TO audit_log_old;
|
||||
ALTER TABLE audit_log_partitioned RENAME TO audit_log;
|
||||
|
||||
-- Step 5: Update sequences and indexes
|
||||
-- (handled by the partitioned table definition)
|
||||
|
||||
-- Step 6: Drop old table after verification
|
||||
-- DROP TABLE audit_log_old;
|
||||
```
|
||||
|
||||
### Automating Partition Creation
|
||||
|
||||
Use the `setup_audit_partitioning.sql` script to automate partition management:
|
||||
|
||||
```bash
|
||||
# Run the setup script
|
||||
psql -h localhost -U leocrm -d crm_db -f scripts/setup_audit_partitioning.sql
|
||||
```
|
||||
|
||||
### Cron Job for Partition Maintenance
|
||||
|
||||
Add to crontab to create partitions automatically:
|
||||
|
||||
```bash
|
||||
# Run on the 1st of each month at 2 AM
|
||||
0 2 1 * * /usr/bin/psql -h localhost -U leocrm -d crm_db -c "SELECT create_monthly_audit_partition();"
|
||||
```
|
||||
|
||||
### Querying Partitioned Data
|
||||
|
||||
```sql
|
||||
-- Query a specific month (fast, only scans one partition)
|
||||
SELECT * FROM audit_log
|
||||
WHERE created_at >= '2026-01-01'
|
||||
AND created_at < '2026-02-01'
|
||||
AND tenant_id = '...';
|
||||
|
||||
-- Query across months (scans multiple partitions)
|
||||
SELECT * FROM audit_log
|
||||
WHERE created_at >= '2026-01-01'
|
||||
AND created_at < '2026-03-01'
|
||||
AND action = 'permission_grant';
|
||||
|
||||
-- Check which partitions will be scanned
|
||||
EXPLAIN SELECT * FROM audit_log
|
||||
WHERE created_at >= '2026-01-01'
|
||||
AND created_at < '2026-02-01';
|
||||
```
|
||||
|
||||
### Dropping Old Partitions
|
||||
|
||||
```sql
|
||||
-- Drop partitions older than retention period
|
||||
DROP TABLE IF EXISTS audit_log_2025_01;
|
||||
DROP TABLE IF EXISTS audit_log_2025_02;
|
||||
-- ...
|
||||
|
||||
-- Or use a function
|
||||
SELECT drop_old_audit_partitions(12); -- Keep last 12 months
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Index each partition** — Don't rely on parent table indexes
|
||||
- **Use `created_at` in WHERE** — Always filter by date for partition pruning
|
||||
- **Monitor partition count** — Too many partitions can slow planning
|
||||
- **Archive old partitions** — Consider moving to cheaper storage
|
||||
- **Vacuum partitions** — Each partition needs independent vacuum
|
||||
|
||||
### Monitoring Partition Health
|
||||
|
||||
```sql
|
||||
-- Check partition sizes
|
||||
SELECT
|
||||
relname AS partition_name,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
|
||||
FROM pg_catalog.pg_statio_user_tables
|
||||
WHERE relname LIKE 'audit_log_%'
|
||||
ORDER BY relname;
|
||||
|
||||
-- Check row counts per partition
|
||||
SELECT
|
||||
relname AS partition_name,
|
||||
n_live_tup AS row_count
|
||||
FROM pg_catalog.pg_stat_user_tables
|
||||
WHERE relname LIKE 'audit_log_%'
|
||||
ORDER BY relname;
|
||||
|
||||
-- List all partitions
|
||||
SELECT
|
||||
inhrelid::regclass AS partition_name
|
||||
FROM pg_catalog.pg_inherits
|
||||
WHERE inhparent = 'audit_log'::regclass
|
||||
ORDER BY partition_name;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Full backup
|
||||
pg_dump -h localhost -U leocrm -d crm_db -F c -f /backups/crm_db_$(date +%Y%m%d).dump
|
||||
|
||||
# Backup with compression
|
||||
pg_dump -h localhost -U leocrm -d crm_db -F c -Z 9 -f /backups/crm_db_$(date +%Y%m%d).dump.gz
|
||||
|
||||
# Backup specific schema only
|
||||
pg_dump -h localhost -U leocrm -d crm_db -n public -F c -f /backups/crm_db_schema_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### Database Restore
|
||||
|
||||
```bash
|
||||
# Restore full backup
|
||||
pg_restore -h localhost -U leocrm -d crm_db -c /backups/crm_db_20260701.dump
|
||||
|
||||
# Restore with parallel workers (faster)
|
||||
pg_restore -h localhost -U leocrm -d crm_db -j 4 -c /backups/crm_db_20260701.dump
|
||||
```
|
||||
|
||||
### Automated Backup Script
|
||||
|
||||
See `scripts/backup.py` for the automated backup solution.
|
||||
|
||||
---
|
||||
|
||||
## 4. Monitoring and Alerts
|
||||
|
||||
### Key Metrics
|
||||
|
||||
| Metric | Target | Alert Threshold |
|
||||
|--------|--------|----------------|
|
||||
| Database connections | < 50 | > 80% of max |
|
||||
| Query response time | < 100ms | > 500ms |
|
||||
| Cache hit ratio | > 95% | < 90% |
|
||||
| Partition size | < 10GB | > 50GB |
|
||||
| PgBouncer pool usage | < 80% | > 90% |
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check PgBouncer status
|
||||
echo "SHOW STATS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer | grep -E "total_|avg_"
|
||||
|
||||
# Check partition health
|
||||
psql -h localhost -U leocrm -d crm_db -c "SELECT count(*) FROM audit_log WHERE created_at < NOW() - INTERVAL '3 months';"
|
||||
|
||||
# Check database size
|
||||
psql -h localhost -U leocrm -d crm_db -c "SELECT pg_size_pretty(pg_database_size('crm_db'));"
|
||||
```
|
||||
@@ -0,0 +1,331 @@
|
||||
# LeoCRM Permission System
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Date:** 2026-07-29
|
||||
> **Applies to:** All developers and system administrators
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
The LeoCRM permission system is a multi-layered access control framework that combines:
|
||||
|
||||
- **Row-Level Ownership** — Each entity can have an `owner_id` (user who owns it)
|
||||
- **Entity Permissions (ACL)** — Explicit permission entries for users, groups, or roles
|
||||
- **ABAC Policies** — Attribute-based policies for fine-grained access control
|
||||
- **Role-Based Access Control (RBAC)** — Module-level permissions via user roles
|
||||
- **System Admin Override** — System administrators see everything
|
||||
|
||||
### Permission Resolution Order
|
||||
|
||||
When checking access to an entity, the system resolves in this order (highest wins):
|
||||
|
||||
1. **System Admin** → `delete` (full access to everything)
|
||||
2. **Owner** → `owner` (from `owner_id` on the entity)
|
||||
3. **Direct User Permission** → explicit ACL entry for the user
|
||||
4. **Group Permission** → ACL entry for a group the user belongs to
|
||||
5. **Role Permission** → ACL entry for the user's role
|
||||
6. **Tenant-Owned** → `read` (if `owner_id IS NULL`, visible to all with module permission)
|
||||
7. **No Access** → `none`
|
||||
|
||||
### Permission Levels
|
||||
|
||||
| Level | Value | Description |
|
||||
|-------|-------|-------------|
|
||||
| `none` | 0 | Explicit deny (overrides allow) |
|
||||
| `read` | 1 | View the entity |
|
||||
| `write` | 2 | Read + edit entity fields |
|
||||
| `admin` | 3 | Write + delete + manage permissions |
|
||||
| `delete` | 4 | Admin + transfer ownership |
|
||||
| `owner` | 5 | Full control (automatic for owner) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Model
|
||||
|
||||
### EntityPermission
|
||||
|
||||
Stored in the `entity_permissions` table:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `tenant_id` | UUID | Tenant scope |
|
||||
| `entity_type` | String(50) | Entity type (e.g., 'contact', 'dms_file') |
|
||||
| `entity_id` | UUID | The specific entity |
|
||||
| `principal_type` | String(10) | 'user', 'group', 'role', 'guest' |
|
||||
| `principal_id` | UUID | The user/group/role ID |
|
||||
| `permission_level` | String(20) | 'none', 'read', 'write', 'admin', 'delete' |
|
||||
| `expires_at` | DateTime | Optional expiration |
|
||||
| `created_by` | UUID | Who created this permission |
|
||||
| `created_at` | DateTime | Creation timestamp |
|
||||
| `updated_at` | DateTime | Last update timestamp |
|
||||
|
||||
### EntityPolicy (ABAC)
|
||||
|
||||
Stored in the `entity_policies` table:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `tenant_id` | UUID | Tenant scope |
|
||||
| `name` | String(200) | Policy name |
|
||||
| `entity_type` | String(50) | Target entity type |
|
||||
| `principal_type` | String(10) | 'user', 'group', 'role' |
|
||||
| `principal_id` | UUID | Target principal |
|
||||
| `effect` | String(10) | 'allow' or 'deny' |
|
||||
| `conditions` | JSONB | Attribute-based conditions |
|
||||
| `priority` | Integer | Evaluation priority (higher = first) |
|
||||
| `enabled` | Boolean | Whether the policy is active |
|
||||
| `created_at` | DateTime | Creation timestamp |
|
||||
| `updated_at` | DateTime | Last update timestamp |
|
||||
|
||||
### OwnedMixin
|
||||
|
||||
Adds `owner_id` to any model:
|
||||
|
||||
- `NULL` → Tenant-owned (visible to all with module permission)
|
||||
- `UUID` → Owned by that user
|
||||
- Set automatically by service layer on creation
|
||||
- Transfer requires owner, admin, or system_admin role
|
||||
|
||||
---
|
||||
|
||||
## 3. Entity Permissions API
|
||||
|
||||
### List Permissions
|
||||
|
||||
```
|
||||
GET /api/v1/{entity_type}/{entity_id}/permissions
|
||||
```
|
||||
|
||||
Returns all permission entries for an entity.
|
||||
|
||||
### Create/Update Permission
|
||||
|
||||
```
|
||||
POST /api/v1/{entity_type}/{entity_id}/permissions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"access_level": "read",
|
||||
"expires_at": "2026-12-31T23:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Revoke Permission
|
||||
|
||||
```
|
||||
DELETE /api/v1/{entity_type}/{entity_id}/permissions/{user_id}
|
||||
```
|
||||
|
||||
### Check Access
|
||||
|
||||
```
|
||||
GET /api/v1/{entity_type}/{entity_id}/access?user_id={uuid}
|
||||
```
|
||||
|
||||
Returns the effective access level for a user.
|
||||
|
||||
---
|
||||
|
||||
## 4. ABAC Policies
|
||||
|
||||
### Policy Conditions Format
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "status", "op": "eq", "value": "active"},
|
||||
{"field": "amount", "op": "gte", "value": 1000},
|
||||
{"field": "tags", "op": "contains", "value": "vip"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|--------|
|
||||
| `eq` | Equals | `{"field": "type", "op": "eq", "value": "company"}` |
|
||||
| `neq` | Not equals | `{"field": "status", "op": "neq", "value": "archived"}` |
|
||||
| `gt` | Greater than | `{"field": "amount", "op": "gt", "value": 100}` |
|
||||
| `gte` | Greater or equal | `{"field": "amount", "op": "gte", "value": 50}` |
|
||||
| `lt` | Less than | `{"field": "amount", "op": "lt", "value": 10000}` |
|
||||
| `lte` | Less or equal | `{"field": "amount", "op": "lte", "value": 500}` |
|
||||
| `in` | In list | `{"field": "status", "op": "in", "value": ["active", "pending"]}` |
|
||||
| `not_in` | Not in list | `{"field": "status", "op": "not_in", "value": ["deleted"]}` |
|
||||
| `contains` | String contains | `{"field": "name", "op": "contains", "value": "VIP"}` |
|
||||
| `starts_with` | String starts with | `{"field": "name", "op": "starts_with", "value": "Confidential"}` |
|
||||
| `is_null` | Is NULL | `{"field": "email", "op": "is_null"}` |
|
||||
| `is_not_null` | Is not NULL | `{"field": "email", "op": "is_not_null"}` |
|
||||
|
||||
### Policy Evaluation
|
||||
|
||||
1. **Allow policies**: OR-joined (at least one must match for access)
|
||||
2. **Deny policies**: NOT (none may match — deny takes precedence)
|
||||
3. **Priority**: Higher priority policies evaluated first
|
||||
4. **Enabled flag**: Disabled policies are skipped
|
||||
|
||||
---
|
||||
|
||||
## 5. Service Layer
|
||||
|
||||
### Entity Permission Service (`app/services/entity_permission_service.py`)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `get_effective_access()` | Get access level for a user on a specific entity |
|
||||
| `get_visible_ids()` | Get all visible entity IDs for a user |
|
||||
| `batch_get_effective_access()` | Batch resolve access for multiple entities |
|
||||
| `check_entity_access()` | Check if user has at least required level |
|
||||
| `get_cached_visible_ids()` | Get visible IDs with Redis caching |
|
||||
| `create_permission()` | Create or update a permission entry |
|
||||
| `update_permission()` | Update an existing permission |
|
||||
| `delete_permission()` | Delete a permission entry |
|
||||
| `list_permissions()` | List all permissions for an entity |
|
||||
| `list_all_permissions()` | List all permissions for a tenant |
|
||||
| `cleanup_expired_permissions()` | Remove expired permission entries |
|
||||
| `invalidate_all_user_entity_cache()` | Clear all cached permissions for a user |
|
||||
| `get_permission_analytics()` | Get permission statistics |
|
||||
|
||||
### Policy Service (`app/services/policy_service.py`)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `create_policy()` | Create a new ABAC policy |
|
||||
| `update_policy()` | Update an existing policy |
|
||||
| `delete_policy()` | Delete a policy |
|
||||
| `list_policies()` | List policies for a tenant |
|
||||
| `build_sql_condition()` | Translate JSONB conditions to SQLAlchemy filters |
|
||||
| `apply_policy_filter()` | Apply ABAC policies to a query |
|
||||
|
||||
---
|
||||
|
||||
## 6. Caching
|
||||
|
||||
The permission system uses Redis for caching visibility results:
|
||||
|
||||
- **Cache key**: `ep_vis:{user_id}:{tenant_id}:{entity_type}`
|
||||
- **Cache value**: JSON with `visible_ids` and `access_map`
|
||||
- **TTL**: 5 minutes (300 seconds)
|
||||
- **Invalidation**: Automatic on permission create/update/delete
|
||||
|
||||
### Cache Flow
|
||||
|
||||
1. Check Redis cache for user + entity type
|
||||
2. Cache hit → return cached visible IDs
|
||||
3. Cache miss → resolve from database, store in cache
|
||||
4. Permission changes → invalidate affected user caches
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Considerations
|
||||
|
||||
- **Batch resolution** (`batch_get_effective_access`) is preferred over individual `get_effective_access` calls
|
||||
- **Redis caching** reduces database load for repeated visibility checks
|
||||
- **Bitmap optimization** for large entity sets (planned)
|
||||
- **Indexes** on `entity_type + entity_id`, `principal_type + principal_id`, `tenant_id`, `expires_at`
|
||||
- **Partitioning** recommended for `entity_permissions` table at scale
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
- **Deny takes precedence** over allow in both ACL and ABAC
|
||||
- **Expired permissions** are automatically excluded from resolution
|
||||
- **System admin** bypasses all permission checks
|
||||
- **Audit logging** for all permission changes
|
||||
- **Notifications** sent to users when permissions are granted/revoked
|
||||
- **Tenant isolation** enforced via `tenant_id` on all permission entries
|
||||
|
||||
---
|
||||
|
||||
## 9. Examples
|
||||
|
||||
### Grant Read Access to a User
|
||||
|
||||
```python
|
||||
from app.services import entity_permission_service as eps
|
||||
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
permission_level="read",
|
||||
created_by=current_user.id,
|
||||
)
|
||||
```
|
||||
|
||||
### Check Access
|
||||
|
||||
```python
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, user_id, "contact", contact_id
|
||||
)
|
||||
if access in ("read", "write", "admin", "delete", "owner"):
|
||||
# User has access
|
||||
pass
|
||||
```
|
||||
|
||||
### Create ABAC Policy
|
||||
|
||||
```python
|
||||
from app.services import policy_service as ps
|
||||
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="VIP Only",
|
||||
entity_type="contact",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="allow",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "company"},
|
||||
{"field": "name", "op": "contains", "value": "VIP"},
|
||||
]
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Batch Resolve Access
|
||||
|
||||
```python
|
||||
result = await eps.batch_get_effective_access(
|
||||
db_session, tenant_id, user_id, "contact", entity_ids
|
||||
)
|
||||
for entity_id, level in result.items():
|
||||
print(f"Entity {entity_id}: {level}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| User sees nothing | No permissions, not owner, not system admin | Grant explicit permission or set owner_id |
|
||||
| Expired permission still works | Cache not invalidated | Wait for TTL or invalidate cache manually |
|
||||
| ABAC policy not applied | Policy disabled or no conditions match | Check `enabled` flag and conditions |
|
||||
| System admin can't see entity | Entity deleted or wrong tenant | Check `deleted_at` and `tenant_id` |
|
||||
| Permission creation fails | Duplicate unique constraint | Use upsert (create_permission handles this) |
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging:
|
||||
```python
|
||||
import logging
|
||||
logging.getLogger("app.services.entity_permission_service").setLevel(logging.DEBUG)
|
||||
logging.getLogger("app.services.policy_service").setLevel(logging.DEBUG)
|
||||
```
|
||||
@@ -0,0 +1,509 @@
|
||||
# Permission System Plugin Development Guide
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Date:** 2026-07-29
|
||||
> **Applies to:** Plugin developers integrating with the LeoCRM permission system
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Plugins can leverage the LeoCRM permission system to add row-level access control to their entities. This guide covers:
|
||||
|
||||
- Adding `OwnedMixin` to plugin models
|
||||
- Using `apply_visibility_filter()` for list queries
|
||||
- Registering entity types for permission management
|
||||
- Defining field-level permissions
|
||||
- Integrating with ABAC policies
|
||||
|
||||
---
|
||||
|
||||
## 2. Adding OwnedMixin to Plugin Models
|
||||
|
||||
To enable ownership tracking for your plugin's entities, add `OwnedMixin` to your SQLAlchemy model:
|
||||
|
||||
```python
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
class MyEntity(Base, TenantMixin, OwnedMixin):
|
||||
__tablename__ = "my_entities"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
### Owner ID Semantics
|
||||
|
||||
- `NULL` → Tenant-owned (visible to all with module permission)
|
||||
- `UUID` → Owned by that user (visible to owner + shared via entity_permissions)
|
||||
- Set automatically by service layer on creation
|
||||
- Transfer requires owner, admin, or system_admin role
|
||||
|
||||
### Setting Owner on Creation
|
||||
|
||||
```python
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
async def create_entity(db: AsyncSession, data: dict, current_user: User):
|
||||
entity = MyEntity(
|
||||
tenant_id=current_user.tenant_id,
|
||||
owner_id=current_user.id, # Set owner automatically
|
||||
**data,
|
||||
)
|
||||
db.add(entity)
|
||||
await db.commit()
|
||||
await db.refresh(entity)
|
||||
return entity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Using apply_visibility_filter()
|
||||
|
||||
The `apply_visibility_filter()` function filters a query to only return entities the user can see. This is the recommended way to implement list endpoints.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from app.services import entity_permission_service as eps
|
||||
|
||||
async def list_entities(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[MyEntity]:
|
||||
# Get visible entity IDs
|
||||
visible_ids, access_map = await eps.get_visible_ids(
|
||||
db, tenant_id, user_id, "my_entity"
|
||||
)
|
||||
|
||||
if not visible_ids:
|
||||
return []
|
||||
|
||||
# Query only visible entities
|
||||
result = await db.execute(
|
||||
select(MyEntity)
|
||||
.where(MyEntity.id.in_(visible_ids))
|
||||
.where(MyEntity.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
### With Caching
|
||||
|
||||
```python
|
||||
async def list_entities_cached(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[MyEntity]:
|
||||
# Use cached version for better performance
|
||||
visible_ids, access_map = await eps.get_cached_visible_ids(
|
||||
db, redis, tenant_id, user_id, "my_entity"
|
||||
)
|
||||
|
||||
if not visible_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(MyEntity)
|
||||
.where(MyEntity.id.in_(visible_ids))
|
||||
.where(MyEntity.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
### With Access Level in Response
|
||||
|
||||
```python
|
||||
async def list_entities_with_access(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[dict]:
|
||||
visible_ids, access_map = await eps.get_visible_ids(
|
||||
db, tenant_id, user_id, "my_entity"
|
||||
)
|
||||
|
||||
if not visible_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(MyEntity)
|
||||
.where(MyEntity.id.in_(visible_ids))
|
||||
.where(MyEntity.tenant_id == tenant_id)
|
||||
)
|
||||
entities = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
**entity.to_dict(),
|
||||
"access_level": access_map.get(entity.id, "none"),
|
||||
}
|
||||
for entity in entities
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Entity Registration
|
||||
|
||||
Register your entity type so it appears in the permission management UI and API.
|
||||
|
||||
### In Your Plugin Manifest
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
class MyPluginManifest(PluginManifest):
|
||||
# ... other fields
|
||||
entity_types: list[str] = ["my_entity"]
|
||||
```
|
||||
|
||||
### In Your Plugin Class
|
||||
|
||||
```python
|
||||
from app.plugins.base import BasePlugin
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
@property
|
||||
def entity_types(self) -> list[str]:
|
||||
return ["my_entity"]
|
||||
```
|
||||
|
||||
### Registering Routes for Permission Management
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
router = APIRouter(prefix="/api/v1/my-entities")
|
||||
|
||||
@router.get("/{entity_id}/permissions")
|
||||
async def list_my_entity_permissions(
|
||||
entity_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all permissions for a specific entity."""
|
||||
return await eps.list_permissions(
|
||||
db, current_user.tenant_id, "my_entity", str(entity_id)
|
||||
)
|
||||
|
||||
@router.post("/{entity_id}/permissions")
|
||||
async def create_my_entity_permission(
|
||||
entity_id: uuid.UUID,
|
||||
body: PermissionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Grant permission to a user for this entity."""
|
||||
return await eps.create_permission(
|
||||
db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
entity_type="my_entity",
|
||||
entity_id=str(entity_id),
|
||||
principal_type="user",
|
||||
principal_id=str(body.user_id),
|
||||
permission_level=body.access_level,
|
||||
expires_at=body.expires_at,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Field Definitions
|
||||
|
||||
Define which fields of your entity are visible at each permission level.
|
||||
|
||||
### Field Permission Schema
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
|
||||
class EntityFieldDefinition(BaseModel):
|
||||
"""Define field visibility per permission level."""
|
||||
name: str
|
||||
type: str # "string", "number", "boolean", "date", "reference"
|
||||
required: bool = False
|
||||
readable: dict[str, bool] = {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"admin": True,
|
||||
"delete": True,
|
||||
"owner": True,
|
||||
}
|
||||
writable: dict[str, bool] = {
|
||||
"read": False,
|
||||
"write": True,
|
||||
"admin": True,
|
||||
"delete": True,
|
||||
"owner": True,
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Field Definitions
|
||||
|
||||
```python
|
||||
from app.core.permission_registry import register_entity_fields
|
||||
|
||||
FIELD_DEFINITIONS = [
|
||||
EntityFieldDefinition(
|
||||
name="name",
|
||||
type="string",
|
||||
required=True,
|
||||
readable={"read": True, "write": True, "admin": True, "delete": True, "owner": True},
|
||||
writable={"read": False, "write": True, "admin": True, "delete": True, "owner": True},
|
||||
),
|
||||
EntityFieldDefinition(
|
||||
name="sensitive_data",
|
||||
type="string",
|
||||
required=False,
|
||||
readable={"read": False, "write": False, "admin": True, "delete": True, "owner": True},
|
||||
writable={"read": False, "write": False, "admin": True, "delete": True, "owner": True},
|
||||
),
|
||||
]
|
||||
|
||||
# Register during plugin activation
|
||||
register_entity_fields("my_entity", FIELD_DEFINITIONS)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Integrating with ABAC Policies
|
||||
|
||||
Your plugin can create and manage ABAC policies for its entities.
|
||||
|
||||
### Creating Policies
|
||||
|
||||
```python
|
||||
from app.services import policy_service as ps
|
||||
|
||||
# Create an allow policy
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="Allow VIP my_entities",
|
||||
entity_type="my_entity",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="allow",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "priority", "op": "gte", "value": 10},
|
||||
]
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Applying Policies to Queries
|
||||
|
||||
```python
|
||||
query = select(MyEntity).where(MyEntity.tenant_id == tenant_id)
|
||||
query = await ps.apply_policy_filter(
|
||||
db_session, query, "my_entity", user_id, tenant_id, MyEntity
|
||||
)
|
||||
result = await db_session.execute(query)
|
||||
entities = result.scalars().all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices
|
||||
|
||||
### Do's
|
||||
|
||||
- ✅ Always set `owner_id` on entity creation
|
||||
- ✅ Use `get_visible_ids()` for list endpoints
|
||||
- ✅ Use `batch_get_effective_access()` for bulk operations
|
||||
- ✅ Register entity types for permission management
|
||||
- ✅ Define field-level permissions for sensitive data
|
||||
- ✅ Use Redis caching for frequently accessed permissions
|
||||
- ✅ Handle permission expiration gracefully
|
||||
|
||||
### Don'ts
|
||||
|
||||
- ❌ Don't bypass permission checks for list endpoints
|
||||
- ❌ Don't expose `owner_id` changes without authorization
|
||||
- ❌ Don't create permissions without audit logging
|
||||
- ❌ Don't forget to invalidate cache after permission changes
|
||||
- ❌ Don't use `get_effective_access()` in loops — use batch instead
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.services import entity_permission_service as eps
|
||||
from tests.conftest import seed_tenant_and_users
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_entity_permissions(db_session):
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create entity with owner
|
||||
entity = MyEntity(
|
||||
tenant_id=tenant_id,
|
||||
owner_id=user_id,
|
||||
name="Test Entity",
|
||||
)
|
||||
db_session.add(entity)
|
||||
await db_session.commit()
|
||||
|
||||
# Check access
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, user_id, "my_entity", entity.id
|
||||
)
|
||||
assert access == "owner"
|
||||
```
|
||||
|
||||
### Mocking Permissions
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
async def test_list_with_mock_permissions():
|
||||
with patch(
|
||||
"app.services.entity_permission_service.get_visible_ids",
|
||||
new=AsyncMock(return_value=({uuid.UUID(int=1)}, {uuid.UUID(int=1): "read"})),
|
||||
):
|
||||
# Your test code
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. API Reference
|
||||
|
||||
### Entity Permission Service
|
||||
|
||||
| Function | Import | Description |
|
||||
|----------|--------|-------------|
|
||||
| `get_effective_access()` | `from app.services import entity_permission_service as eps` | Get access level for a user on a specific entity |
|
||||
| `get_visible_ids()` | Same | Get all visible entity IDs for a user |
|
||||
| `batch_get_effective_access()` | Same | Batch resolve access for multiple entities |
|
||||
| `check_entity_access()` | Same | Check if user has at least required level |
|
||||
| `get_cached_visible_ids()` | Same | Get visible IDs with Redis caching |
|
||||
| `create_permission()` | Same | Create or update a permission entry |
|
||||
| `update_permission()` | Same | Update an existing permission |
|
||||
| `delete_permission()` | Same | Delete a permission entry |
|
||||
| `list_permissions()` | Same | List all permissions for an entity |
|
||||
|
||||
### Policy Service
|
||||
|
||||
| Function | Import | Description |
|
||||
|----------|--------|-------------|
|
||||
| `create_policy()` | `from app.services import policy_service as ps` | Create a new ABAC policy |
|
||||
| `update_policy()` | Same | Update an existing policy |
|
||||
| `delete_policy()` | Same | Delete a policy |
|
||||
| `list_policies()` | Same | List policies for a tenant |
|
||||
| `build_sql_condition()` | Same | Translate JSONB conditions to SQLAlchemy filters |
|
||||
| `apply_policy_filter()` | Same | Apply ABAC policies to a query |
|
||||
|
||||
---
|
||||
|
||||
## 10. Example: Complete Plugin Integration
|
||||
|
||||
```python
|
||||
"""Example plugin with full permission system integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.services import entity_permission_service as eps
|
||||
|
||||
|
||||
class MyEntity(Base, TenantMixin, OwnedMixin):
|
||||
"""Example entity with permission support."""
|
||||
|
||||
__tablename__ = "my_entities"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
priority: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
async def create_my_entity(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
priority: int = 0,
|
||||
) -> MyEntity:
|
||||
"""Create a new entity with owner set."""
|
||||
entity = MyEntity(
|
||||
tenant_id=tenant_id,
|
||||
owner_id=user_id,
|
||||
name=name,
|
||||
priority=priority,
|
||||
)
|
||||
db.add(entity)
|
||||
await db.commit()
|
||||
await db.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
async def list_visible_entities(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[MyEntity]:
|
||||
"""List entities visible to the user."""
|
||||
visible_ids, _ = await eps.get_visible_ids(
|
||||
db, tenant_id, user_id, "my_entity"
|
||||
)
|
||||
if not visible_ids:
|
||||
return []
|
||||
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(
|
||||
select(MyEntity)
|
||||
.where(MyEntity.id.in_(visible_ids))
|
||||
.where(MyEntity.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def share_entity(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_id: uuid.UUID,
|
||||
target_user_id: uuid.UUID,
|
||||
level: str,
|
||||
created_by: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Share an entity with another user."""
|
||||
return await eps.create_permission(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="my_entity",
|
||||
entity_id=str(entity_id),
|
||||
principal_type="user",
|
||||
principal_id=str(target_user_id),
|
||||
permission_level=level,
|
||||
created_by=created_by,
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users, LogOut, Search, Eye, Mail, Phone, Building2, User, Calendar } from 'lucide-react';
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
position?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function GuestContactsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [guestInfo, setGuestInfo] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch guest info
|
||||
fetch('/api/v1/guest/me', { credentials: 'include' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Not authenticated');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setGuestInfo({ name: data.name, email: data.email }))
|
||||
.catch(() => {
|
||||
navigate('/guest/login');
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const fetchContacts = async (query?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = query
|
||||
? `/api/v1/contacts?search=${encodeURIComponent(query)}&limit=50`
|
||||
: '/api/v1/contacts?limit=50';
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-CSRF-Token': '',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load contacts');
|
||||
}
|
||||
const data = await response.json();
|
||||
setContacts(data.items || data.data || data || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load contacts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
fetchContacts(search);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/v1/guest/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
navigate('/guest/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-primary-600 mr-3" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Shared Contacts
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{guestInfo && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{guestInfo.name} ({guestInfo.email})
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<form onSubmit={handleSearch} className="mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search shared contacts..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 mb-6">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contacts Grid */}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{contacts.length} contact{contacts.length !== 1 ? 's' : ''} shared with you
|
||||
</p>
|
||||
{contacts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Users className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No contacts</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
No contacts have been shared with you yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600 dark:text-primary-300" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{contact.first_name} {contact.last_name}
|
||||
</h3>
|
||||
{contact.company && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center mt-1">
|
||||
<Building2 className="h-3 w-3 mr-1" />
|
||||
{contact.company}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Eye className="h-4 w-4 text-gray-400" title="Read-only" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{contact.email && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 flex items-center">
|
||||
<Mail className="h-4 w-4 mr-2 text-gray-400" />
|
||||
{contact.email}
|
||||
</p>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 flex items-center">
|
||||
<Phone className="h-4 w-4 mr-2 text-gray-400" />
|
||||
{contact.phone}
|
||||
</p>
|
||||
)}
|
||||
{contact.position && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 flex items-center">
|
||||
<Building2 className="h-4 w-4 mr-2 text-gray-400" />
|
||||
{contact.position}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-400 flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
Created: {new Date(contact.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Eye, EyeOff, LogIn, Mail, Lock, Building2 } from 'lucide-react';
|
||||
|
||||
interface GuestLoginResponse {
|
||||
guest_user_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tenant_id: string;
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
export function GuestLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [email, setEmail] = useState(searchParams.get('email') || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [tenantSlug, setTenantSlug] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/guest/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, tenant_slug: tenantSlug }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail?.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: GuestLoginResponse = await response.json();
|
||||
// Redirect to guest contacts page
|
||||
navigate('/guest/contacts');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Guest Login
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Sign in with your guest credentials to access shared contacts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tenantSlug" className="sr-only">
|
||||
Tenant (optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Building2 className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="tenantSlug"
|
||||
name="tenantSlug"
|
||||
type="text"
|
||||
value={tenantSlug}
|
||||
onChange={(e) => setTenantSlug(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Tenant slug (optional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center">
|
||||
<LogIn className="h-5 w-5 mr-2" />
|
||||
Sign in as Guest
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { AppShell } from '@/components/layout/AppShell';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { ProtectedRoute as PermissionRoute } from '@/components/common/ProtectedRoute';
|
||||
import { LoginPage } from '@/pages/Login';
|
||||
import { GuestLoginPage } from '@/pages/GuestLogin';
|
||||
import { GuestContactsPage } from '@/pages/GuestContacts';
|
||||
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
||||
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
@@ -110,6 +112,14 @@ const router = createBrowserRouter([
|
||||
path: '/password-reset/confirm',
|
||||
element: <PasswordResetConfirmPage />,
|
||||
},
|
||||
{
|
||||
path: '/guest/login',
|
||||
element: <GuestLoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/guest/contacts',
|
||||
element: <GuestContactsPage />,
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
--
|
||||
-- Audit Log Partitioning Setup for LeoCRM
|
||||
-- =========================================
|
||||
--
|
||||
-- This script creates a partitioned audit_log table with monthly partitions.
|
||||
-- It includes functions for automatic partition creation and maintenance.
|
||||
--
|
||||
-- Usage:
|
||||
-- psql -h localhost -U leocrm -d crm_db -f scripts/setup_audit_partitioning.sql
|
||||
--
|
||||
-- The script is idempotent — safe to run multiple times.
|
||||
--
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 1. Create the partitioned audit_log table
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
-- Check if the partitioned table already exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_class WHERE relname = 'audit_log' AND relkind = 'p'
|
||||
) THEN
|
||||
-- Check if there's an existing non-partitioned table
|
||||
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'audit_log' AND relkind = 'r') THEN
|
||||
-- Rename existing table
|
||||
ALTER TABLE audit_log RENAME TO audit_log_old;
|
||||
RAISE NOTICE 'Renamed existing audit_log to audit_log_old';
|
||||
END IF;
|
||||
|
||||
-- Create the partitioned table
|
||||
CREATE TABLE audit_log (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
user_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50),
|
||||
entity_id UUID,
|
||||
changes JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
RAISE NOTICE 'Created partitioned audit_log table';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 2. Function: Create a single monthly partition
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_monthly_audit_partition(
|
||||
partition_date DATE DEFAULT CURRENT_DATE
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
year_str TEXT;
|
||||
month_str TEXT;
|
||||
partition_name TEXT;
|
||||
start_date TEXT;
|
||||
end_date TEXT;
|
||||
BEGIN
|
||||
year_str := TO_CHAR(partition_date, 'YYYY');
|
||||
month_str := TO_CHAR(partition_date, 'MM');
|
||||
partition_name := 'audit_log_' || year_str || '_' || month_str;
|
||||
start_date := year_str || '-' || month_str || '-01';
|
||||
end_date := TO_CHAR(partition_date + INTERVAL '1 month', 'YYYY-MM-DD');
|
||||
|
||||
-- Check if partition already exists
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_class WHERE relname = partition_name
|
||||
) THEN
|
||||
EXECUTE format('
|
||||
CREATE TABLE %I PARTITION OF audit_log
|
||||
FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date
|
||||
);
|
||||
|
||||
-- Create indexes on the new partition
|
||||
EXECUTE format('
|
||||
CREATE INDEX %I ON %I (tenant_id)',
|
||||
'idx_' || partition_name || '_tenant', partition_name
|
||||
);
|
||||
EXECUTE format('
|
||||
CREATE INDEX %I ON %I (action)',
|
||||
'idx_' || partition_name || '_action', partition_name
|
||||
);
|
||||
EXECUTE format('
|
||||
CREATE INDEX %I ON %I (entity_type, entity_id)',
|
||||
'idx_' || partition_name || '_entity', partition_name
|
||||
);
|
||||
EXECUTE format('
|
||||
CREATE INDEX %I ON %I (created_at DESC)',
|
||||
'idx_' || partition_name || '_created', partition_name
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Created partition: %', partition_name;
|
||||
ELSE
|
||||
RAISE NOTICE 'Partition already exists: %', partition_name;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 3. Function: Create partitions for the next N months
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_future_audit_partitions(
|
||||
months_ahead INT DEFAULT 3
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
i INT;
|
||||
partition_date DATE;
|
||||
BEGIN
|
||||
-- Create current month partition first
|
||||
partition_date := DATE_TRUNC('month', CURRENT_DATE)::DATE;
|
||||
PERFORM create_monthly_audit_partition(partition_date);
|
||||
|
||||
-- Create future month partitions
|
||||
FOR i IN 1..months_ahead LOOP
|
||||
partition_date := (DATE_TRUNC('month', CURRENT_DATE) + (i || ' months')::INTERVAL)::DATE;
|
||||
PERFORM create_monthly_audit_partition(partition_date);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Created % future partitions (current + % months)', months_ahead + 1, months_ahead;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 4. Function: Drop old partitions beyond retention period
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION drop_old_audit_partitions(
|
||||
retention_months INT DEFAULT 12
|
||||
)
|
||||
RETURNS INT
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
partition_record RECORD;
|
||||
drop_count INT := 0;
|
||||
cutoff_date DATE;
|
||||
BEGIN
|
||||
cutoff_date := DATE_TRUNC('month', CURRENT_DATE - (retention_months || ' months')::INTERVAL)::DATE;
|
||||
|
||||
FOR partition_record IN
|
||||
SELECT
|
||||
inhrelid::regclass AS partition_name,
|
||||
pg_get_expr(relpartbound, relid) AS partition_bound
|
||||
FROM pg_catalog.pg_inherits
|
||||
JOIN pg_class ON pg_class.oid = inhrelid
|
||||
WHERE inhparent = 'audit_log'::regclass
|
||||
LOOP
|
||||
-- Extract the upper bound date from the partition bound expression
|
||||
-- Format: FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
|
||||
IF partition_record.partition_bound ~ 'TO \(''([0-9]{4}-[0-9]{2}-[0-9]{2})' THEN
|
||||
DECLARE
|
||||
upper_date DATE;
|
||||
BEGIN
|
||||
upper_date := SUBSTRING(
|
||||
partition_record.partition_bound
|
||||
FROM 'TO \(''([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
||||
)::DATE;
|
||||
|
||||
IF upper_date <= cutoff_date THEN
|
||||
EXECUTE format('DROP TABLE IF EXISTS %I', partition_record.partition_name);
|
||||
drop_count := drop_count + 1;
|
||||
RAISE NOTICE 'Dropped old partition: %', partition_record.partition_name;
|
||||
END IF;
|
||||
END;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN drop_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 5. Create initial partitions (current + next 3 months)
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SELECT create_future_audit_partitions(3);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 6. Migrate existing data (if any)
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
old_count BIGINT;
|
||||
new_count BIGINT;
|
||||
BEGIN
|
||||
-- Check if old table exists and has data
|
||||
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'audit_log_old') THEN
|
||||
EXECUTE 'SELECT COUNT(*) FROM audit_log_old' INTO old_count;
|
||||
|
||||
IF old_count > 0 THEN
|
||||
RAISE NOTICE 'Migrating % rows from audit_log_old...', old_count;
|
||||
|
||||
-- Insert data into partitioned table
|
||||
EXECUTE '
|
||||
INSERT INTO audit_log (
|
||||
id, tenant_id, user_id, action, entity_type,
|
||||
entity_id, changes, ip_address, user_agent, created_at
|
||||
)
|
||||
SELECT
|
||||
id, tenant_id, user_id, action, entity_type,
|
||||
entity_id, changes, ip_address, user_agent, created_at
|
||||
FROM audit_log_old
|
||||
';
|
||||
|
||||
EXECUTE 'SELECT COUNT(*) FROM audit_log' INTO new_count;
|
||||
RAISE NOTICE 'Migration complete: % rows migrated', new_count;
|
||||
|
||||
-- Verify data integrity
|
||||
IF old_count = new_count THEN
|
||||
RAISE NOTICE 'Data integrity verified: % rows match', old_count;
|
||||
ELSE
|
||||
RAISE WARNING 'Data mismatch: old=% rows, new=% rows', old_count, new_count;
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE NOTICE 'No data to migrate in audit_log_old';
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 7. Verify setup
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
partition_count INT;
|
||||
table_type TEXT;
|
||||
BEGIN
|
||||
-- Check table type
|
||||
SELECT relkind INTO table_type FROM pg_class WHERE relname = 'audit_log';
|
||||
|
||||
IF table_type = 'p' THEN
|
||||
RAISE NOTICE 'audit_log is correctly set up as a partitioned table';
|
||||
ELSE
|
||||
RAISE WARNING 'audit_log is NOT a partitioned table (relkind=%)', table_type;
|
||||
END IF;
|
||||
|
||||
-- Count partitions
|
||||
SELECT COUNT(*) INTO partition_count
|
||||
FROM pg_catalog.pg_inherits
|
||||
WHERE inhparent = 'audit_log'::regclass;
|
||||
|
||||
RAISE NOTICE 'Number of partitions: %', partition_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- Usage Examples
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Create partitions for the next 6 months:
|
||||
-- SELECT create_future_audit_partitions(6);
|
||||
--
|
||||
-- Drop partitions older than 12 months:
|
||||
-- SELECT drop_old_audit_partitions(12);
|
||||
--
|
||||
-- Create a specific month partition:
|
||||
-- SELECT create_monthly_audit_partition('2026-07-01'::DATE);
|
||||
--
|
||||
-- Check partition sizes:
|
||||
-- SELECT
|
||||
-- relname AS partition_name,
|
||||
-- pg_size_pretty(pg_total_relation_size(relid)) AS total_size
|
||||
-- FROM pg_catalog.pg_statio_user_tables
|
||||
-- WHERE relname LIKE 'audit_log_%'
|
||||
-- ORDER BY relname;
|
||||
--
|
||||
-- Check row counts per partition:
|
||||
-- SELECT
|
||||
-- relname AS partition_name,
|
||||
-- n_live_tup AS row_count
|
||||
-- FROM pg_catalog.pg_stat_user_tables
|
||||
-- WHERE relname LIKE 'audit_log_%'
|
||||
-- ORDER BY relname;
|
||||
--
|
||||
-- Cron job (run on 1st of each month at 2 AM):
|
||||
-- 0 2 1 * * /usr/bin/psql -h localhost -U leocrm -d crm_db -c "SELECT create_future_audit_partitions(3);"
|
||||
--
|
||||
Executable
+279
@@ -0,0 +1,279 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# PgBouncer Setup Script for LeoCRM
|
||||
# ===================================
|
||||
#
|
||||
# This script installs and configures PgBouncer for PostgreSQL connection pooling.
|
||||
# It creates the configuration files and starts the service.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup_pgbouncer.sh [--docker] [--password PASSWORD]
|
||||
#
|
||||
# Options:
|
||||
# --docker Configure for Docker Compose environment
|
||||
# --password PASS Set PostgreSQL password (default: from .env or prompt)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Color Output ───
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# ─── Default Values ───
|
||||
PGBOUNCER_VERSION="1.23.1"
|
||||
PGBOUNCER_PORT="6432"
|
||||
POOL_MODE="transaction"
|
||||
DEFAULT_POOL_SIZE="25"
|
||||
MAX_CLIENT_CONN="200"
|
||||
|
||||
# ─── Parse Arguments ───
|
||||
DOCKER_MODE=false
|
||||
POSTGRES_PASSWORD=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--docker)
|
||||
DOCKER_MODE=true
|
||||
shift
|
||||
;;
|
||||
--password)
|
||||
POSTGRES_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: $0 [--docker] [--password PASSWORD]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --docker Configure for Docker Compose environment"
|
||||
echo " --password PASS Set PostgreSQL password (default: from .env or prompt)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ─── Detect Environment ───
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||
source "$PROJECT_DIR/.env"
|
||||
fi
|
||||
|
||||
if [ -z "$POSTGRES_PASSWORD" ]; then
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$POSTGRES_PASSWORD" ]; then
|
||||
read -s -p "Enter PostgreSQL password: " POSTGRES_PASSWORD
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ─── Docker Mode ───
|
||||
if [ "$DOCKER_MODE" = true ]; then
|
||||
log_info "Configuring PgBouncer for Docker Compose..."
|
||||
|
||||
# Check if docker-compose.yml exists
|
||||
if [ ! -f "$PROJECT_DIR/docker-compose.yml" ]; then
|
||||
log_error "docker-compose.yml not found in $PROJECT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if PgBouncer service already exists
|
||||
if grep -q "pgbouncer" "$PROJECT_DIR/docker-compose.yml" 2>/dev/null; then
|
||||
log_warn "PgBouncer service already exists in docker-compose.yml"
|
||||
else
|
||||
log_info "Adding PgBouncer service to docker-compose.yml..."
|
||||
|
||||
# Add PgBouncer service before the last line of services
|
||||
cat >> "$PROJECT_DIR/docker-compose.yml" << 'EOF'
|
||||
|
||||
pgbouncer:
|
||||
image: bitnami/pgbouncer:latest
|
||||
container_name: leocrm-pgbouncer
|
||||
ports:
|
||||
- "6432:6432"
|
||||
environment:
|
||||
- POSTGRESQL_HOST=crm-postgres
|
||||
- POSTGRESQL_PORT=5432
|
||||
- POSTGRESQL_USERNAME=leocrm
|
||||
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRESQL_DATABASE=crm_db
|
||||
- PGBOUNCER_POOL_MODE=transaction
|
||||
- PGBOUNCER_DEFAULT_POOL_SIZE=25
|
||||
- PGBOUNCER_MAX_CLIENT_CONN=200
|
||||
depends_on:
|
||||
- crm-postgres
|
||||
restart: unless-stopped
|
||||
EOF
|
||||
log_info "PgBouncer service added to docker-compose.yml"
|
||||
fi
|
||||
|
||||
log_info "Docker PgBouncer configuration complete!"
|
||||
log_info "Run 'docker-compose up -d pgbouncer' to start PgBouncer"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Native Installation ───
|
||||
log_info "Installing PgBouncer v${PGBOUNCER_VERSION}..."
|
||||
|
||||
# Check if PgBouncer is already installed
|
||||
if command -v pgbouncer &>/dev/null; then
|
||||
log_info "PgBouncer is already installed: $(pgbouncer --version)"
|
||||
else
|
||||
# Install PgBouncer
|
||||
if command -v apt-get &>/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y pgbouncer
|
||||
elif command -v yum &>/dev/null; then
|
||||
yum install -y pgbouncer
|
||||
else
|
||||
log_error "Unsupported package manager. Install PgBouncer manually."
|
||||
exit 1
|
||||
fi
|
||||
log_info "PgBouncer installed successfully"
|
||||
fi
|
||||
|
||||
# ─── Create Configuration ───
|
||||
log_info "Creating PgBouncer configuration..."
|
||||
|
||||
PGBOUNCER_CONF_DIR="/etc/pgbouncer"
|
||||
mkdir -p "$PGBOUNCER_CONF_DIR"
|
||||
|
||||
# Generate md5 password hash
|
||||
PG_MD5_HASH=$(echo -n "md5$(echo -n "${POSTGRES_PASSWORD}leocrm" | md5sum | cut -d' ' -f1)")
|
||||
|
||||
# Create pgbouncer.ini
|
||||
cat > "${PGBOUNCER_CONF_DIR}/pgbouncer.ini" << INI
|
||||
[databases]
|
||||
leocrm = host=localhost port=5432 dbname=crm_db
|
||||
leocrm_test = host=localhost port=5432 dbname=leocrm_test
|
||||
|
||||
[pgbouncer]
|
||||
listen_addr = 0.0.0.0
|
||||
listen_port = ${PGBOUNCER_PORT}
|
||||
unix_socket_dir = /var/run/pgbouncer
|
||||
|
||||
auth_type = md5
|
||||
auth_file = ${PGBOUNCER_CONF_DIR}/userlist.txt
|
||||
|
||||
pool_mode = ${POOL_MODE}
|
||||
default_pool_size = ${DEFAULT_POOL_SIZE}
|
||||
max_client_conn = ${MAX_CLIENT_CONN}
|
||||
max_db_connections = 50
|
||||
|
||||
server_idle_timeout = 600
|
||||
server_lifetime = 3600
|
||||
client_idle_timeout = 1800
|
||||
query_timeout = 30
|
||||
|
||||
log_connections = 1
|
||||
log_disconnections = 1
|
||||
log_pooler_errors = 1
|
||||
stats_period = 60
|
||||
|
||||
listen_backlog = 128
|
||||
INI
|
||||
|
||||
log_info "Created ${PGBOUNCER_CONF_DIR}/pgbouncer.ini"
|
||||
|
||||
# Create userlist.txt
|
||||
cat > "${PGBOUNCER_CONF_DIR}/userlist.txt" << USERLIST
|
||||
"leocrm" "${PG_MD5_HASH}"
|
||||
"postgres" "${PG_MD5_HASH}"
|
||||
USERLIST
|
||||
|
||||
log_info "Created ${PGBOUNCER_CONF_DIR}/userlist.txt"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 640 "${PGBOUNCER_CONF_DIR}/pgbouncer.ini"
|
||||
chmod 640 "${PGBOUNCER_CONF_DIR}/userlist.txt"
|
||||
chown -R pgbouncer:pgbouncer "$PGBOUNCER_CONF_DIR" 2>/dev/null || true
|
||||
|
||||
# ─── Create Systemd Service ───
|
||||
log_info "Creating systemd service..."
|
||||
|
||||
cat > /etc/systemd/system/pgbouncer.service << 'SYSTEMD'
|
||||
[Unit]
|
||||
Description=PgBouncer PostgreSQL Connection Pooler
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=pgbouncer
|
||||
ExecStart=/usr/sbin/pgbouncer -d /etc/pgbouncer/pgbouncer.ini
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
ExecStop=/bin/kill -INT $MAINPID
|
||||
PIDFile=/var/run/pgbouncer/pgbouncer.pid
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SYSTEMD
|
||||
|
||||
log_info "Created systemd service"
|
||||
|
||||
# ─── Start PgBouncer ───
|
||||
log_info "Starting PgBouncer..."
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable pgbouncer
|
||||
systemctl start pgbouncer
|
||||
|
||||
# Wait for PgBouncer to start
|
||||
sleep 2
|
||||
|
||||
# Check status
|
||||
if systemctl is-active --quiet pgbouncer; then
|
||||
log_info "PgBouncer is running on port ${PGBOUNCER_PORT}"
|
||||
else
|
||||
log_error "PgBouncer failed to start. Check logs: journalctl -u pgbouncer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── Verify Connection ───
|
||||
log_info "Verifying PgBouncer connection..."
|
||||
|
||||
if command -v psql &>/dev/null; then
|
||||
if PGPASSWORD="$POSTGRES_PASSWORD" psql -h localhost -p "$PGBOUNCER_PORT" -U leocrm -d leocrm -c "SELECT 1 AS pgbouncer_test;" &>/dev/null; then
|
||||
log_info "PgBouncer connection verified successfully!"
|
||||
else
|
||||
log_warn "Could not verify PgBouncer connection. Check PostgreSQL credentials."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Summary ───
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo " PgBouncer Setup Complete"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Configuration:"
|
||||
echo " Config file: ${PGBOUNCER_CONF_DIR}/pgbouncer.ini"
|
||||
echo " User list: ${PGBOUNCER_CONF_DIR}/userlist.txt"
|
||||
echo " Listen port: ${PGBOUNCER_PORT}"
|
||||
echo " Pool mode: ${POOL_MODE}"
|
||||
echo " Pool size: ${DEFAULT_POOL_SIZE}"
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo " Status: systemctl status pgbouncer"
|
||||
echo " Restart: systemctl restart pgbouncer"
|
||||
echo " Reload: systemctl reload pgbouncer"
|
||||
echo " Stop: systemctl stop pgbouncer"
|
||||
echo ""
|
||||
echo " Monitoring:"
|
||||
echo " Pools: echo 'SHOW POOLS;' | psql -h localhost -p ${PGBOUNCER_PORT} -U leocrm -d pgbouncer"
|
||||
echo " Stats: echo 'SHOW STATS;' | psql -h localhost -p ${PGBOUNCER_PORT} -U leocrm -d pgbouncer"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Tests for ABAC policy service — attribute-based access control policies.
|
||||
|
||||
Covers:
|
||||
- Policy allow (conditions match → access granted)
|
||||
- Policy deny (conditions match → access blocked)
|
||||
- Multi-condition AND (all conditions must match)
|
||||
- Multi-condition OR (any condition must match)
|
||||
- Policy CRUD operations
|
||||
- build_sql_condition translation
|
||||
- apply_policy_filter integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.services import policy_service as ps
|
||||
from tests.conftest import seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestABACPolicyService:
|
||||
"""Tests for ABAC policy service — attribute-based access control."""
|
||||
|
||||
async def test_policy_allow(self, db_session: AsyncSession):
|
||||
"""Allow policy with matching conditions grants access."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create an allow policy for contacts where type == 'company'
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="Allow company contacts",
|
||||
entity_type="contact",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="allow",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "company"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert policy["effect"] == "allow"
|
||||
assert policy["name"] == "Allow company contacts"
|
||||
assert policy["entity_type"] == "contact"
|
||||
assert policy["conditions"]["rules"][0]["field"] == "type"
|
||||
|
||||
# Verify the policy was persisted
|
||||
db_policy = await db_session.get(EntityPolicy, uuid.UUID(policy["id"]))
|
||||
assert db_policy is not None
|
||||
assert db_policy.effect == "allow"
|
||||
|
||||
async def test_policy_deny(self, db_session: AsyncSession):
|
||||
"""Deny policy blocks access when conditions match."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create a deny policy for contacts where name contains 'Confidential'
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="Deny confidential contacts",
|
||||
entity_type="contact",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="deny",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "name", "op": "starts_with", "value": "Confidential"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert policy["effect"] == "deny"
|
||||
assert policy["enabled"] is True
|
||||
|
||||
async def test_multi_condition_and(self, db_session: AsyncSession):
|
||||
"""AND conditions: all rules must match for the policy to apply."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create policy with AND conditions
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="VIP company contacts",
|
||||
entity_type="contact",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="allow",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "company"},
|
||||
{"field": "name", "op": "contains", "value": "VIP"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert policy["conditions"]["operator"] == "AND"
|
||||
assert len(policy["conditions"]["rules"]) == 2
|
||||
|
||||
# Test build_sql_condition translation
|
||||
condition = ps.build_sql_condition(policy["conditions"], Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_multi_condition_or(self, db_session: AsyncSession):
|
||||
"""OR conditions: any rule matching triggers the policy."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create policy with OR conditions
|
||||
policy = await ps.create_policy(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
name="High value or VIP contacts",
|
||||
entity_type="contact",
|
||||
principal_type="user",
|
||||
principal_id=str(user_id),
|
||||
effect="allow",
|
||||
conditions={
|
||||
"operator": "OR",
|
||||
"rules": [
|
||||
{"field": "name", "op": "contains", "value": "VIP"},
|
||||
{"field": "name", "op": "contains", "value": "Premium"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert policy["conditions"]["operator"] == "OR"
|
||||
assert len(policy["conditions"]["rules"]) == 2
|
||||
|
||||
# Test build_sql_condition translation
|
||||
condition = ps.build_sql_condition(policy["conditions"], Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_build_sql_condition_eq(self, db_session: AsyncSession):
|
||||
"""build_sql_condition translates 'eq' operator correctly."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "person"}
|
||||
]
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_build_sql_condition_gt(self, db_session: AsyncSession):
|
||||
"""build_sql_condition translates 'gt' operator correctly."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "id", "op": "gt", "value": 100}
|
||||
]
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_build_sql_condition_contains(self, db_session: AsyncSession):
|
||||
"""build_sql_condition translates 'contains' operator correctly."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "name", "op": "contains", "value": "test"}
|
||||
]
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_build_sql_condition_nested(self, db_session: AsyncSession):
|
||||
"""build_sql_condition handles nested conditions blocks."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"operator": "OR",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "company"},
|
||||
{"field": "type", "op": "eq", "value": "person"},
|
||||
]
|
||||
},
|
||||
{"field": "name", "op": "is_not_null", "value": None}
|
||||
]
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is not None
|
||||
|
||||
async def test_build_sql_condition_empty_rules(self, db_session: AsyncSession):
|
||||
"""build_sql_condition returns None for empty rules."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": []
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is None
|
||||
|
||||
async def test_build_sql_condition_unsupported_op(self, db_session: AsyncSession):
|
||||
"""build_sql_condition skips unsupported operators gracefully."""
|
||||
conditions = {
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "name", "op": "unsupported_op", "value": "test"}
|
||||
]
|
||||
}
|
||||
condition = ps.build_sql_condition(conditions, Contact)
|
||||
assert condition is None
|
||||
|
||||
async def test_list_policies(self, db_session: AsyncSession):
|
||||
"""list_policies returns all policies for a tenant."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create two policies
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Policy 1",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Policy 2",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="deny",
|
||||
)
|
||||
|
||||
policies = await ps.list_policies(db_session, tenant_id)
|
||||
assert len(policies) >= 2
|
||||
names = [p["name"] for p in policies]
|
||||
assert "Policy 1" in names
|
||||
assert "Policy 2" in names
|
||||
|
||||
async def test_list_policies_filtered_by_entity_type(self, db_session: AsyncSession):
|
||||
"""list_policies filters by entity_type when provided."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Contact Policy",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="File Policy",
|
||||
entity_type="dms_file", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
|
||||
contact_policies = await ps.list_policies(db_session, tenant_id, entity_type="contact")
|
||||
assert len(contact_policies) >= 1
|
||||
assert all(p["entity_type"] == "contact" for p in contact_policies)
|
||||
|
||||
async def test_update_policy(self, db_session: AsyncSession):
|
||||
"""update_policy modifies an existing policy."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
policy = await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Original",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
|
||||
updated = await ps.update_policy(
|
||||
db_session, tenant_id, policy["id"],
|
||||
name="Updated", effect="deny",
|
||||
)
|
||||
|
||||
assert updated["name"] == "Updated"
|
||||
assert updated["effect"] == "deny"
|
||||
|
||||
async def test_delete_policy(self, db_session: AsyncSession):
|
||||
"""delete_policy removes a policy."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
policy = await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="To Delete",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
|
||||
await ps.delete_policy(db_session, tenant_id, policy["id"])
|
||||
|
||||
# Verify it's gone
|
||||
policies = await ps.list_policies(db_session, tenant_id)
|
||||
ids = [p["id"] for p in policies]
|
||||
assert policy["id"] not in ids
|
||||
|
||||
async def test_apply_policy_filter_no_policies(self, db_session: AsyncSession):
|
||||
"""apply_policy_filter returns query unchanged when no policies match."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
query = select(Contact).where(Contact.tenant_id == tenant_id)
|
||||
result = await ps.apply_policy_filter(
|
||||
db_session, query, "contact", user_id, tenant_id, Contact
|
||||
)
|
||||
|
||||
# Query should be unchanged (no ABAC restriction)
|
||||
rows = await db_session.execute(result)
|
||||
contacts = rows.scalars().all()
|
||||
assert len(contacts) >= 1
|
||||
|
||||
async def test_apply_policy_filter_with_allow(self, db_session: AsyncSession):
|
||||
"""apply_policy_filter with allow policy filters correctly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create allow policy for company-type contacts
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Only companies",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "type", "op": "eq", "value": "company"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
query = select(Contact).where(Contact.tenant_id == tenant_id)
|
||||
result = await ps.apply_policy_filter(
|
||||
db_session, query, "contact", user_id, tenant_id, Contact
|
||||
)
|
||||
|
||||
rows = await db_session.execute(result)
|
||||
contacts = rows.scalars().all()
|
||||
assert len(contacts) >= 1
|
||||
for c in contacts:
|
||||
assert c.type == "company", f"Expected 'company', got '{c.type}'"
|
||||
|
||||
async def test_apply_policy_filter_with_deny(self, db_session: AsyncSession):
|
||||
"""apply_policy_filter with deny policy blocks matching entities."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create deny policy for contacts named 'Company Alpha'
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Block Company Alpha",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="deny",
|
||||
conditions={
|
||||
"operator": "AND",
|
||||
"rules": [
|
||||
{"field": "name", "op": "eq", "value": "Company Alpha"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
query = select(Contact).where(Contact.tenant_id == tenant_id)
|
||||
result = await ps.apply_policy_filter(
|
||||
db_session, query, "contact", user_id, tenant_id, Contact
|
||||
)
|
||||
|
||||
rows = await db_session.execute(result)
|
||||
contacts = rows.scalars().all()
|
||||
# Company Alpha should be filtered out
|
||||
names = [c.name for c in contacts]
|
||||
assert "Company Alpha" not in names, "Deny policy should have blocked Company Alpha"
|
||||
|
||||
async def test_policy_priority_ordering(self, db_session: AsyncSession):
|
||||
"""Policies are ordered by priority (higher first)."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Low Priority",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow", priority=1,
|
||||
)
|
||||
await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="High Priority",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow", priority=100,
|
||||
)
|
||||
|
||||
policies = await ps.list_policies(db_session, tenant_id, entity_type="contact")
|
||||
# High priority should come first
|
||||
high_idx = next(i for i, p in enumerate(policies) if p["name"] == "High Priority")
|
||||
low_idx = next(i for i, p in enumerate(policies) if p["name"] == "Low Priority")
|
||||
assert high_idx < low_idx, "High priority policy should come first"
|
||||
|
||||
async def test_policy_enabled_flag(self, db_session: AsyncSession):
|
||||
"""Disabled policies are not applied."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
policy = await ps.create_policy(
|
||||
db_session, tenant_id=tenant_id, name="Disabled Policy",
|
||||
entity_type="contact", principal_type="user",
|
||||
principal_id=str(user_id), effect="allow",
|
||||
)
|
||||
|
||||
# Disable the policy
|
||||
updated = await ps.update_policy(
|
||||
db_session, tenant_id, policy["id"], enabled=False
|
||||
)
|
||||
assert updated["enabled"] is False
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for entity permission service — ACL resolution, ownership, sharing, expiration.
|
||||
|
||||
Covers:
|
||||
- Owner sees own contacts
|
||||
- Non-owner doesn't see others' contacts
|
||||
- Shared user sees shared contacts
|
||||
- Permission expiration
|
||||
- System admin sees all
|
||||
- Batch resolution
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.user import User
|
||||
from app.services import entity_permission_service as eps
|
||||
from tests.conftest import seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEntityPermissions:
|
||||
"""Tests for entity permission service — ACL resolution, ownership, sharing."""
|
||||
|
||||
async def test_owner_sees_own_contacts(self, db_session: AsyncSession):
|
||||
"""Owner has 'owner' access level on their own contacts."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = user_id
|
||||
await db_session.commit()
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, user_id, "contact", contact_id
|
||||
)
|
||||
assert access == "owner", f"Expected 'owner', got '{access}'"
|
||||
|
||||
async def test_non_owner_doesnt_see_others_contacts(self, db_session: AsyncSession):
|
||||
"""Non-owner without explicit permission gets 'none' access."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
other_user_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, other_user_id, "contact", contact_id
|
||||
)
|
||||
assert access == "none", f"Expected 'none', got '{access}'"
|
||||
|
||||
async def test_shared_user_sees_shared_contacts(self, db_session: AsyncSession):
|
||||
"""User with explicit 'read' permission sees the contact."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
shared_user_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read permission to viewer
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(shared_user_id),
|
||||
permission_level="read",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, shared_user_id, "contact", contact_id
|
||||
)
|
||||
assert access == "read", f"Expected 'read', got '{access}'"
|
||||
|
||||
async def test_permission_expiration(self, db_session: AsyncSession):
|
||||
"""Expired permission returns 'none' access."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
shared_user_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read permission that expired 1 hour ago
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(shared_user_id),
|
||||
permission_level="read",
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, shared_user_id, "contact", contact_id
|
||||
)
|
||||
assert access == "none", f"Expected 'none' for expired permission, got '{access}'"
|
||||
|
||||
async def test_system_admin_sees_all(self, db_session: AsyncSession):
|
||||
"""System admin gets 'delete' access on any entity."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Create a system admin user
|
||||
from app.core.auth import hash_password
|
||||
sys_admin = User(
|
||||
email="sysadmin@test.com",
|
||||
name="System Admin",
|
||||
password_hash=hash_password("TestPass123!"),
|
||||
is_active=True,
|
||||
is_system_admin=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(sys_admin)
|
||||
await db_session.commit()
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, sys_admin.id, "contact", contact_id
|
||||
)
|
||||
assert access == "delete", f"Expected 'delete' for system admin, got '{access}'"
|
||||
|
||||
async def test_batch_resolution(self, db_session: AsyncSession):
|
||||
"""Batch resolution returns correct access levels for multiple entities."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Create a second contact owned by viewer
|
||||
contact2 = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="company",
|
||||
name="Company Viewer",
|
||||
displayname="Company Viewer",
|
||||
owner_id=viewer_id,
|
||||
created_by=viewer_id,
|
||||
updated_by=viewer_id,
|
||||
)
|
||||
db_session.add(contact2)
|
||||
await db_session.commit()
|
||||
|
||||
# Set owner on first contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read permission to viewer on first contact
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="read",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
# Batch resolve for viewer
|
||||
result = await eps.batch_get_effective_access(
|
||||
db_session, tenant_id, viewer_id, "contact", [contact_id, contact2.id]
|
||||
)
|
||||
|
||||
assert result[contact_id] == "read", f"Expected 'read' for shared contact, got '{result[contact_id]}'"
|
||||
assert result[contact2.id] == "owner", f"Expected 'owner' for own contact, got '{result[contact2.id]}'"
|
||||
|
||||
async def test_tenant_owned_contact_visible_to_all(self, db_session: AsyncSession):
|
||||
"""Tenant-owned contact (owner_id IS NULL) is visible as 'read' to all tenant users."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Ensure owner_id is NULL
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = None
|
||||
await db_session.commit()
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id
|
||||
)
|
||||
assert access == "read", f"Expected 'read' for tenant-owned contact, got '{access}'"
|
||||
|
||||
async def test_group_permission_propagation(self, db_session: AsyncSession):
|
||||
"""User inherits permissions from group membership."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Create a group and add viewer to it
|
||||
from app.models.group import Group, UserGroup
|
||||
group = Group(tenant_id=tenant_id, name="Viewers")
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
|
||||
ug = UserGroup(tenant_id=tenant_id, user_id=viewer_id, group_id=group.id)
|
||||
db_session.add(ug)
|
||||
await db_session.commit()
|
||||
|
||||
# Grant permission to the group
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="group",
|
||||
principal_id=str(group.id),
|
||||
permission_level="write",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id
|
||||
)
|
||||
assert access == "write", f"Expected 'write' via group, got '{access}'"
|
||||
|
||||
async def test_highest_permission_wins(self, db_session: AsyncSession):
|
||||
"""When multiple permissions exist, the highest level wins."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read + write permissions (write should win)
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="read",
|
||||
created_by=owner_id,
|
||||
)
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="write",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id
|
||||
)
|
||||
assert access == "write", f"Expected 'write' (highest wins), got '{access}'"
|
||||
|
||||
async def test_visible_ids_returns_owned_and_shared(self, db_session: AsyncSession):
|
||||
"""get_visible_ids returns owned + shared + tenant-owned entities."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Create a second contact owned by viewer
|
||||
contact2 = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="company",
|
||||
name="Viewer Owned",
|
||||
displayname="Viewer Owned",
|
||||
owner_id=viewer_id,
|
||||
created_by=viewer_id,
|
||||
updated_by=viewer_id,
|
||||
)
|
||||
db_session.add(contact2)
|
||||
await db_session.commit()
|
||||
|
||||
# Set owner on first contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read to viewer on first contact
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="read",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
visible, access_map = await eps.get_visible_ids(
|
||||
db_session, tenant_id, viewer_id, "contact"
|
||||
)
|
||||
|
||||
assert contact_id in visible, "Shared contact should be visible"
|
||||
assert contact2.id in visible, "Owned contact should be visible"
|
||||
assert access_map[contact_id] == "read"
|
||||
assert access_map[contact2.id] == "owner"
|
||||
|
||||
async def test_check_entity_access_enforces_required_level(self, db_session: AsyncSession):
|
||||
"""check_entity_access returns True/False based on required level."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id on the contact
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Grant read to viewer
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="read",
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
# Should have read access
|
||||
assert await eps.check_entity_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id, "read"
|
||||
) is True
|
||||
|
||||
# Should NOT have write access
|
||||
assert await eps.check_entity_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id, "write"
|
||||
) is False
|
||||
|
||||
async def test_cleanup_expired_permissions(self, db_session: AsyncSession):
|
||||
"""cleanup_expired_permissions removes expired entries."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Create expired permission
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level="read",
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
created_by=owner_id,
|
||||
)
|
||||
|
||||
count = await eps.cleanup_expired_permissions(db_session)
|
||||
assert count >= 1, "Expected at least 1 expired permission cleaned up"
|
||||
|
||||
# Verify it's gone
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, viewer_id, "contact", contact_id
|
||||
)
|
||||
assert access == "none", "Permission should be gone after cleanup"
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Performance tests for permission system — visibility filter, caching.
|
||||
|
||||
Covers:
|
||||
- Visibility filter performance with 1000 mock contacts
|
||||
- Cache hit vs miss performance comparison
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.services import entity_permission_service as eps
|
||||
from tests.conftest import seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPermissionPerformance:
|
||||
"""Performance tests for permission system."""
|
||||
|
||||
async def test_visibility_filter_performance(self, db_session: AsyncSession):
|
||||
"""get_visible_ids with 1000 contacts completes in reasonable time."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create 1000 contacts owned by the user
|
||||
contacts = []
|
||||
for i in range(1000):
|
||||
contacts.append(Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="person",
|
||||
first_name=f"Perf{i}",
|
||||
last_name=f"Test{i}",
|
||||
owner_id=user_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
))
|
||||
db_session.add_all(contacts)
|
||||
await db_session.commit()
|
||||
|
||||
# Measure time for get_visible_ids
|
||||
start = time.perf_counter()
|
||||
visible, access_map = await eps.get_visible_ids(
|
||||
db_session, tenant_id, user_id, "contact"
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Should complete in under 2 seconds for 1000 contacts
|
||||
assert elapsed < 2.0, f"get_visible_ids took {elapsed:.3f}s (expected <2.0s)"
|
||||
assert len(visible) >= 1000, f"Expected >=1000 visible, got {len(visible)}"
|
||||
|
||||
# All should be at 'owner' level
|
||||
for eid, level in access_map.items():
|
||||
if eid in [c.id for c in contacts]:
|
||||
assert level == "owner", f"Expected 'owner', got '{level}'"
|
||||
|
||||
async def test_cache_hit_vs_miss(self, db_session: AsyncSession, redis_client):
|
||||
"""Cache hit is significantly faster than cache miss."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create 100 contacts owned by the user
|
||||
contacts = []
|
||||
for i in range(100):
|
||||
contacts.append(Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="person",
|
||||
first_name=f"Cache{i}",
|
||||
last_name=f"Test{i}",
|
||||
owner_id=user_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
))
|
||||
db_session.add_all(contacts)
|
||||
await db_session.commit()
|
||||
|
||||
# First call — cache miss (populates cache)
|
||||
miss_start = time.perf_counter()
|
||||
visible_miss, access_map_miss = await eps.get_cached_visible_ids(
|
||||
db_session, redis_client, tenant_id, user_id, "contact"
|
||||
)
|
||||
miss_elapsed = time.perf_counter() - miss_start
|
||||
|
||||
assert len(visible_miss) >= 100, f"Expected >=100 visible, got {len(visible_miss)}"
|
||||
|
||||
# Second call — cache hit
|
||||
hit_start = time.perf_counter()
|
||||
visible_hit, access_map_hit = await eps.get_cached_visible_ids(
|
||||
db_session, redis_client, tenant_id, user_id, "contact"
|
||||
)
|
||||
hit_elapsed = time.perf_counter() - hit_start
|
||||
|
||||
# Cache hit should be faster (at least 2x speedup)
|
||||
assert hit_elapsed < miss_elapsed, f"Cache hit {hit_elapsed:.4f}s should be faster than miss {miss_elapsed:.4f}s"
|
||||
assert len(visible_hit) == len(visible_miss), "Cache hit should return same results"
|
||||
|
||||
async def test_batch_resolution_performance(self, db_session: AsyncSession):
|
||||
"""batch_get_effective_access with 100 entities completes quickly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Create 100 contacts
|
||||
contacts = []
|
||||
for i in range(100):
|
||||
contacts.append(Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="person",
|
||||
first_name=f"Batch{i}",
|
||||
last_name=f"Test{i}",
|
||||
owner_id=user_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
))
|
||||
db_session.add_all(contacts)
|
||||
await db_session.commit()
|
||||
|
||||
entity_ids = [c.id for c in contacts]
|
||||
|
||||
start = time.perf_counter()
|
||||
result = await eps.batch_get_effective_access(
|
||||
db_session, tenant_id, user_id, "contact", entity_ids
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert elapsed < 1.0, f"batch_get_effective_access took {elapsed:.3f}s (expected <1.0s)"
|
||||
assert len(result) == 100, f"Expected 100 results, got {len(result)}"
|
||||
|
||||
# All should be 'owner'
|
||||
for eid, level in result.items():
|
||||
assert level == "owner", f"Expected 'owner', got '{level}'"
|
||||
|
||||
async def test_cache_invalidation_performance(self, db_session: AsyncSession, redis_client):
|
||||
"""Cache invalidation for all entity types completes quickly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
|
||||
# Populate cache for multiple entity types
|
||||
for entity_type in ["contact", "dms_file", "mailbox", "calendar_event", "task"]:
|
||||
await eps.get_cached_visible_ids(
|
||||
db_session, redis_client, tenant_id, user_id, entity_type
|
||||
)
|
||||
|
||||
# Measure invalidation time
|
||||
start = time.perf_counter()
|
||||
await eps.invalidate_all_user_entity_cache(redis_client, tenant_id, user_id)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert elapsed < 1.0, f"Cache invalidation took {elapsed:.3f}s (expected <1.0s)"
|
||||
|
||||
# Verify cache is cleared
|
||||
for entity_type in ["contact", "dms_file", "mailbox", "calendar_event", "task"]:
|
||||
cache_key = f"ep_vis:{user_id}:{tenant_id}:{entity_type}"
|
||||
cached = await redis_client.get(cache_key)
|
||||
assert cached is None, f"Cache for {entity_type} should be cleared"
|
||||
|
||||
async def test_get_effective_access_performance(self, db_session: AsyncSession):
|
||||
"""get_effective_access for a single entity completes quickly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = user_id
|
||||
await db_session.commit()
|
||||
|
||||
# Measure single access check
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
access = await eps.get_effective_access(
|
||||
db_session, tenant_id, user_id, "contact", contact_id
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
avg_ms = (elapsed / 100) * 1000
|
||||
assert avg_ms < 50, f"Average get_effective_access took {avg_ms:.2f}ms (expected <50ms)"
|
||||
assert access == "owner"
|
||||
|
||||
async def test_check_entity_access_performance(self, db_session: AsyncSession):
|
||||
"""check_entity_access for a single entity completes quickly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
user_id = seed["admin_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = user_id
|
||||
await db_session.commit()
|
||||
|
||||
# Measure single access check
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
result = await eps.check_entity_access(
|
||||
db_session, tenant_id, user_id, "contact", contact_id, "read"
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
avg_ms = (elapsed / 100) * 1000
|
||||
assert avg_ms < 50, f"Average check_entity_access took {avg_ms:.2f}ms (expected <50ms)"
|
||||
assert result is True
|
||||
|
||||
async def test_permission_creation_performance(self, db_session: AsyncSession):
|
||||
"""Creating permissions in bulk completes quickly."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
contact_id = seed["company_a"].id
|
||||
|
||||
# Set owner_id
|
||||
contact = await db_session.get(Contact, contact_id)
|
||||
contact.owner_id = owner_id
|
||||
await db_session.commit()
|
||||
|
||||
# Measure bulk permission creation
|
||||
start = time.perf_counter()
|
||||
for level in ["read", "write", "admin", "delete"]:
|
||||
await eps.create_permission(
|
||||
db_session,
|
||||
tenant_id=tenant_id,
|
||||
entity_type="contact",
|
||||
entity_id=str(contact_id),
|
||||
principal_type="user",
|
||||
principal_id=str(viewer_id),
|
||||
permission_level=level,
|
||||
created_by=owner_id,
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
avg_ms = (elapsed / 4) * 1000
|
||||
assert avg_ms < 200, f"Average permission creation took {avg_ms:.2f}ms (expected <200ms)"
|
||||
|
||||
async def test_visible_ids_with_mixed_ownership(self, db_session: AsyncSession):
|
||||
"""get_visible_ids with mixed ownership (owned + tenant + shared) performs well."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
tenant_id = seed["tenant_a"].id
|
||||
owner_id = seed["admin_a"].id
|
||||
viewer_id = seed["viewer_a"].id
|
||||
|
||||
# Create 500 owned, 300 tenant-owned, 200 shared contacts
|
||||
contacts = []
|
||||
for i in range(500):
|
||||
contacts.append(Contact(
|
||||
tenant_id=tenant_id, type="person",
|
||||
first_name=f"Owned{i}", last_name=f"Test{i}",
|
||||
owner_id=owner_id, created_by=owner_id, updated_by=owner_id,
|
||||
))
|
||||
for i in range(300):
|
||||
contacts.append(Contact(
|
||||
tenant_id=tenant_id, type="person",
|
||||
first_name=f"Tenant{i}", last_name=f"Test{i}",
|
||||
owner_id=None, created_by=owner_id, updated_by=owner_id,
|
||||
))
|
||||
db_session.add_all(contacts)
|
||||
await db_session.commit()
|
||||
|
||||
# Grant viewer access to 200 contacts
|
||||
shared_ids = [c.id for c in contacts[:200]]
|
||||
for cid in shared_ids:
|
||||
await eps.create_permission(
|
||||
db_session, tenant_id=tenant_id,
|
||||
entity_type="contact", entity_id=str(cid),
|
||||
principal_type="user", principal_id=str(viewer_id),
|
||||
permission_level="read", created_by=owner_id,
|
||||
)
|
||||
|
||||
# Measure performance for viewer
|
||||
start = time.perf_counter()
|
||||
visible, access_map = await eps.get_visible_ids(
|
||||
db_session, tenant_id, viewer_id, "contact"
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert elapsed < 3.0, f"get_visible_ids with mixed ownership took {elapsed:.3f}s (expected <3.0s)"
|
||||
# Viewer should see: 200 shared + 300 tenant-owned = 500
|
||||
assert len(visible) >= 500, f"Expected >=500 visible, got {len(visible)}"
|
||||
}
|
||||
Reference in New Issue
Block a user