sprint20-23: tests + documentation + guest access + infrastructure + migrations 0059

This commit is contained in:
Agent Zero
2026-07-29 02:53:37 +02:00
parent ddf73ee42e
commit 24690fb674
20 changed files with 3898 additions and 2 deletions
+34
View File
@@ -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),
+4
View File
@@ -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.
+2
View File
@@ -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",
+60
View File
@@ -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(),
)
+2
View File
@@ -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
)
+170
View File
@@ -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"),
}
+204
View File
@@ -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"}
+25 -2
View File
@@ -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)