Free lesson · GenAI Application Engineering

Build conversation ownership + RBAC sharing

Build a ConversationACL SQLAlchemy model with columns: conversation_id, user_id, access_level (owner/editor/viewer), granted_by, and granted_at. Create POST /conversations/{id}/share accepting a ShareRequest Pydantic model, validating ownership, and generating shareable links via generate_share_token() using secrets.token_urlsafe(32). Implement check_access() as a FastAPI dependency raising HTTP 403 for insufficient permissions. Build ConversationPermissionService with grant_access(), revoke_access(), list_shared_users(), and transfer_ownership(). Store share links in a ShareLink model with token, conversation_id, access_level, expires_at, and max_uses. Add GET /share/{token} that validates and adds the user to the ACL.

Course: Full-Stack GenAI Applications · Chapter 10 · Authentication, Safety & Guardrails

Free to read — no subscription required.

Introduction

When you ship a multi-user GenAI chat app, every conversation carries sensitive context — business data, personal details, reasoning chains — and a single missing permission check can let the wrong user read or inject prompts into someone else's thread. Teams that bolt on sharing as an afterthought end up with privilege-escalation bugs, untraceable access grants, and share links that outlive their intended audience. By the end you'll be able to design a conversation ownership model with role-based access (owner / editor / viewer), enforce it through a reusable FastAPI permission dependency, and issue time-bounded shareable links without compromising your existing JWT and Redis session layer.

Key Terminology

  • Access Control List (ACL): A per-conversation table of rows mapping a user_id to an access_level (owner / editor / viewer) plus audit fields (granted_by, granted_at, expires_at). Every permission check resolves to a single lookup against this table.
  • Role hierarchy (owner / editor / viewer): A strict three-level ordering where owner > editor > viewer. Editors inherit viewer reads and add append rights; owners inherit everything and add share, revoke, and delete rights. Encoded in the ACCESS_HIERARCHY dict so has_minimum_access(required) is a numeric comparison rather than a tangle of if branches.
  • Share link token: A high-entropy URL-safe string (secrets.token_urlsafe(32), ~256 bits) bound to a conversation_id, an access_level, and optional max_uses / expires_at bounds. Cached in Redis for O(1) redemption and persisted in PostgreSQL for audit and revocation.

Concepts

Security Considerations for Shared Conversations

When a viewer or editor accesses a shared conversation, every message they read has already passed through your Llama Guard 4 content classification pipeline. However, editors who can append messages introduce a new attack surface: they could attempt prompt injection through the shared conversation context. Your NeMo Guardrails Colang 2.0 configuration must apply identically regardless of whether the message sender is the owner or an editor. This means the guardrails middleware must key on the conversation_id, not the user_id, when loading rail configurations.

Share link tokens must be treated with the same sensitivity as refresh tokens. Store them hashed (using SHA-256) in PostgreSQL if your threat model includes database breach scenarios—the plaintext token only exists in the URL and the Redis cache. The PromptGuard 2 jailbreak detection layer from LlamaFirewall should also inspect messages from shared-access users, since a shared editor account is a common vector for indirect prompt injection in collaborative AI applications.

Time-limited shares interact with your Redis session TTL system: if a user redeems a share link but their session expires before the share link does, they must re-authenticate through the JWT flow before accessing the conversation again. The expires_at on the ACL entry and the session TTL operate independently—the stricter of the two controls access at any given moment.

Code Walkthrough

Ownership Model Architecture

A conversation ownership system must answer three questions for every API request: Who owns this conversation? What access level does the requester have? How was that access granted? The data model that answers these questions is an Access Control List (ACL) attached to each conversation, where each entry maps a user to a permission level with full audit provenance.

The three access levels form a strict hierarchy:

  • owner: Full control including deletion, sharing, and revoking other users' access. Every conversation has exactly one owner—the user who created it. Ownership is non-transferable through the share endpoint; it requires an explicit ownership transfer operation.
  • editor: Can read the conversation history and append new messages, which means they can invoke the LLM and trigger the NeMo Guardrails safety pipeline on behalf of the conversation. Editors cannot delete the conversation, modify other users' access, or change conversation metadata.
  • viewer: Read-only access to the conversation history. Viewers see the full message thread including any Llama Guard 4 content classification annotations but cannot send new messages or modify anything.

This entity-relationship schema defines the access-control backbone for a multi-user AI chat system. The USER table stores JWT-relevant fields like hashed_password and role, while CONVERSATION_ACL enforces row-level permissions through access_level, granted_by, and expires_at columns—enabling time-bounded, auditable sharing. The SHARE_LINK entity lets conversation owners generate external access tokens without modifying ACL rows directly, keeping the permission model clean and revocable.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares a Mermaid ER (Entity-Relationship) diagram block, indicating the code defines a database schema visualization.
  • Line 2: Defines a one-to-many relationship where one USER creates zero or more CONVERSATION records.
  • Line 3: Defines a one-to-many relationship where one USER is granted access to zero or more CONVERSATION_ACL (access control list) entries.
  • Lines 34-43: Defines the SHARE_LINK entity for shareable conversation links, with a UUID primary key, a conversation_id foreign key, a unique token string used in the URL, an access_level enum controlling what the link grants, a created_by foreign key, max_uses and current_uses integers for limiting link redemptions, and an expires_at timestamp for link expiration.

This entity-relationship diagram shows the four tables that compose the ownership system. The CONVERSATION_ACL table is the core authorization primitive—every permission check queries this table. The SHARE_LINK table enables link-based sharing without requiring the recipient's user ID at share-creation time, which is essential for collaboration workflows where the sharer does not know the recipient's account details in advance.

Implementing the ConversationACL Model

The following code defines the ConversationACL SQLAlchemy model along with the AccessLevel enum and the ShareLink model for link-based sharing. The ConversationACL class uses a unique constraint on (conversation_id, user_id) to prevent duplicate permission entries, and includes an optional expires_at column that integrates with your Redis-backed session TTL system—when a user's session expires, any time-limited share grants also become invalid. The ShareLink model uses Python's secrets.token_urlsafe for cryptographic token generation, ensuring that share URLs cannot be guessed or brute-forced even at scale.

Code snippet python
1import enum 2import secrets 3from datetime import datetime, timezone 4from uuid import uuid4 5 6from sqlalchemy import ( 7 Column, DateTime, Enum, ForeignKey, Integer, 8 String, UniqueConstraint, CheckConstraint, 9) 10from sqlalchemy.dialects.postgresql import UUID 11from sqlalchemy.orm import relationship, validates 12from app.database import Base 13 14class AccessLevel(enum.Enum): 15 OWNER = "owner" 16 EDITOR = "editor" 17 VIEWER = "viewer" 18 19ACCESS_HIERARCHY = { 20 AccessLevel.OWNER: 3, 21 AccessLevel.EDITOR: 2, 22 AccessLevel.VIEWER: 1, 23} 24 25class ConversationACL(Base): 26 __tablename__ = "conversation_acl" 27 __table_args__ = ( 28 UniqueConstraint("conversation_id", "user_id", name="uq_conv_user"), 29 CheckConstraint( 30 "access_level IN ('owner', 'editor', 'viewer')", 31 name="ck_access_level", 32 ), 33 ) 34 35 id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) 36 conversation_id = Column( 37 UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), 38 nullable=False, index=True, 39 ) 40 user_id = Column( 41 UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), 42 nullable=False, index=True, 43 ) 44 access_level = Column(Enum(AccessLevel), nullable=False) 45 granted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) 46 granted_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) 47 expires_at = Column(DateTime(timezone=True), nullable=True) 48 49 conversation = relationship("Conversation", back_populates="acl_entries") 50 user = relationship("User", foreign_keys=[user_id]) 51 granter = relationship("User", foreign_keys=[granted_by]) 52 53 @validates("access_level") 54 def validate_access_level(self, key, value): 55 if isinstance(value, str): 56 return AccessLevel(value) 57 return value 58 59 def is_expired(self) -> bool: 60 if self.expires_at is None: 61 return False 62 return datetime.now(timezone.utc) > self.expires_at 63 64 def has_minimum_access(self, required: AccessLevel) -> bool: 65 if self.is_expired(): 66 return False 67 return ACCESS_HIERARCHY[self.access_level] >= ACCESS_HIERARCHY[required] 68 69class ShareLink(Base): 70 __tablename__ = "share_links" 71 72 id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) 73 conversation_id = Column( 74 UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), 75 nullable=False, 76 ) 77 token = Column(String(64), unique=True, default=lambda: secrets.token_urlsafe(32)) 78 access_level = Column(Enum(AccessLevel), nullable=False, default=AccessLevel.VIEWER) 79 created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) 80 max_uses = Column(Integer, nullable=True) 81 current_uses = Column(Integer, default=0) 82 expires_at = Column(DateTime(timezone=True), nullable=True)
  • Lines 1-6: Import the standard library modules. The secrets module provides token_urlsafe which generates cryptographically secure tokens suitable for share URLs. The enum module defines the access level as a Python enum rather than raw strings, preventing typos and invalid states.
  • Lines 8-12: Import SQLAlchemy column types and constraints. The UUID type from the PostgreSQL dialect stores conversation and user identifiers as native PostgreSQL UUIDs rather than strings, giving you indexed 128-bit keys with no collation overhead.
  • Lines 15-18: Define AccessLevel as a Python enum.Enum with three string values. Using an enum ensures that the database column, Python models, and API schemas all share a single source of truth for valid access levels.
  • Lines 69-82: The ShareLink model stores link-based sharing tokens. The token column defaults to secrets.token_urlsafe(32), producing a 43-character URL-safe string with 256 bits of entropy. The max_uses column enables single-use or limited-use links—when None, the link has unlimited redemptions.

FastAPI Share Endpoint and Permission Dependency

The following code implements the POST /conversations/{id}/share endpoint and a reusable require_access dependency that integrates with the JWT authentication middleware from another goal. The require_access function is a dependency factory—it returns a closure that FastAPI injects into any route that needs permission checking. This pattern avoids repeating authorization queries in every handler. The create_share endpoint validates that the requesting user is the conversation owner before generating a share link, and it stores the link metadata in PostgreSQL while caching the token-to-conversation mapping in Redis for fast redemption lookups that bypass database round-trips.

Code snippet python
1from fastapi import APIRouter, Depends, HTTPException, status 2from pydantic import BaseModel, Field 3from sqlalchemy import select 4from sqlalchemy.ext.asyncio import AsyncSession 5from typing import Optional 6from uuid import UUID 7 8from app.auth import get_current_user 9from app.database import get_db 10from app.redis_client import get_redis 11from app.models import AccessLevel, ConversationACL, ShareLink 12 13router = APIRouter(prefix="/conversations", tags=["sharing"]) 14 15class ShareRequest(BaseModel): 16 access_level: str = Field(default="viewer", pattern="^(editor|viewer)$") 17 max_uses: Optional[int] = Field(default=None, ge=1, le=1000) 18 expires_in_hours: Optional[int] = Field(default=None, ge=1, le=720) 19 20class ShareResponse(BaseModel): 21 share_url: str 22 access_level: str 23 max_uses: Optional[int] 24 expires_at: Optional[str] 25 26def require_access(minimum: AccessLevel): 27 async def dependency( 28 conversation_id: UUID, 29 db: AsyncSession = Depends(get_db), 30 user: dict = Depends(get_current_user), 31 ): 32 result = await db.execute( 33 select(ConversationACL).where( 34 ConversationACL.conversation_id == conversation_id, 35 ConversationACL.user_id == user["sub"], 36 ) 37 ) 38 acl_entry = result.scalar_one_or_none() 39 if acl_entry is None or not acl_entry.has_minimum_access(minimum): 40 raise HTTPException( 41 status_code=status.HTTP_403_FORBIDDEN, 42 detail=f"Requires {minimum.value} access", 43 ) 44 return acl_entry 45 return dependency 46 47@router.post("/{conversation_id}/share", response_model=ShareResponse) 48async def create_share( 49 conversation_id: UUID, 50 body: ShareRequest, 51 acl: ConversationACL = Depends(require_access(AccessLevel.OWNER)), 52 db: AsyncSession = Depends(get_db), 53 redis=Depends(get_redis), 54): 55 expires_at = None 56 if body.expires_in_hours: 57 from datetime import datetime, timedelta, timezone 58 expires_at = datetime.now(timezone.utc) + timedelta(hours=body.expires_in_hours) 59 60 link = ShareLink( 61 conversation_id=conversation_id, 62 access_level=AccessLevel(body.access_level), 63 created_by=acl.user_id, 64 max_uses=body.max_uses, 65 expires_at=expires_at, 66 ) 67 db.add(link) 68 await db.flush() 69 70 cache_key = f"share:{link.token}" 71 cache_value = f"{conversation_id}:{body.access_level}" 72 ttl = body.expires_in_hours * 3600 if body.expires_in_hours else 86400 * 30 73 await redis.setex(cache_key, ttl, cache_value) 74 75 await db.commit() 76 share_url = f"https://app.example.com/share/{link.token}" 77 return ShareResponse( 78 share_url=share_url, 79 access_level=body.access_level, 80 max_uses=body.max_uses, 81 expires_at=expires_at.isoformat() if expires_at else None, 82 )
  • Lines 1-11: Import FastAPI components alongside the custom authentication dependency get_current_user, which extracts and validates the JWT from the Authorization header using PyJWT. The get_redis dependency returns the same Redis connection pool used by the session management layer, ensuring share link caching shares connection resources.
  • Lines 16-19: The ShareRequest Pydantic model constrains the access_level field with a regex pattern that only allows "editor" or "viewer"—owners cannot create share links that grant owner-level access, preventing privilege escalation through sharing. The max_uses field is bounded between 1 and 1000 to prevent abuse.
  • Lines 29-48: The require_access factory function returns an async dependency closure. When FastAPI resolves this dependency, it queries the conversation_acl table for the requesting user's entry on the target conversation. If no entry exists or the entry's access level is below the required minimum, a 403 Forbidden response is returned immediately—the route handler never executes. This pattern centralizes authorization logic so that adding a new endpoint only requires Depends(require_access(AccessLevel.EDITOR)) in the function signature.
  • Lines 78-85: Only after the Redis write succeeds does the transaction commit. The response returns the full share URL, access level, and expiry metadata. In a production system, the URL domain would come from an environment variable rather than a hardcoded string.

Do's and Don'ts

Do's

  1. Do keep SHARE_LINK as a separate table and generate its token with secrets.token_urlsafe — decoupling link-based sharing from CONVERSATION_ACL rows means you can revoke a link by deleting one SHARE_LINK row without disturbing direct ACL grants, and a cryptographically random token ensures share URLs cannot be guessed or brute-forced even at scale.
  2. Do enforce UniqueConstraint("conversation_id", "user_id", name="uq_conv_user") on ConversationACL — concurrent share-link redemptions can race to insert two rows for the same user-conversation pair, producing non-deterministic access_level reads; this constraint is the only guard that makes the "exactly one effective permission per user per conversation" invariant hold under load.
  3. Do set expires_at on every CONVERSATION_ACL row created from a share-link redemption and align that TTL with your Redis session layer — so when a user's session expires, any time-limited grants they received via link sharing also become invalid atomically, preventing share tokens from silently outliving their intended access window.

Don'ts

  1. Don't route permission checks through CONVERSATION.owner_id alone — every FastAPI dependency must query CONVERSATION_ACL directly, because owner_id is blind to editor and viewer grants and cannot evaluate the expires_at column that gates time-bounded share links, leaving those permission paths completely unenforced.
  2. Don't allow the share endpoint to write AccessLevel.OWNER grants — ownership is explicitly non-transferable through the share path; accepting "owner" as a target access_level at the share endpoint lets any current owner duplicate ownership without an explicit transfer operation, breaking the single-owner invariant and corrupting the granted_by audit trail.
  3. Don't let editor-level users call the CONVERSATION_ACL write path — the ACCESS_HIERARCHY dict ranks EDITOR at 2 and OWNER at 3 specifically to encode that modifying other users' access is an owner-only operation; skipping the hierarchy check at the share endpoint is the privilege-escalation vector where an editor silently elevates a viewer to editor without the conversation owner's knowledge.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering