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_idto anaccess_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_HIERARCHYdict sohas_minimum_access(required)is a numeric comparison rather than a tangle ofifbranches. - Share link token: A high-entropy URL-safe string (
secrets.token_urlsafe(32), ~256 bits) bound to aconversation_id, anaccess_level, and optionalmax_uses/expires_atbounds. 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_LINKentity for shareable conversation links, with a UUID primary key, aconversation_idforeign key, a uniquetokenstring used in the URL, anaccess_levelenum controlling what the link grants, acreated_byforeign key,max_usesandcurrent_usesintegers for limiting link redemptions, and anexpires_attimestamp 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
secretsmodule providestoken_urlsafewhich generates cryptographically secure tokens suitable for share URLs. Theenummodule 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
UUIDtype 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
AccessLevelas a Pythonenum.Enumwith 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
ShareLinkmodel stores link-based sharing tokens. Thetokencolumn defaults tosecrets.token_urlsafe(32), producing a 43-character URL-safe string with 256 bits of entropy. Themax_usescolumn 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 theAuthorizationheader using PyJWT. Theget_redisdependency returns the same Redis connection pool used by the session management layer, ensuring share link caching shares connection resources. - Lines 16-19: The
ShareRequestPydantic model constrains theaccess_levelfield 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. Themax_usesfield is bounded between 1 and 1000 to prevent abuse. - Lines 29-48: The
require_accessfactory function returns anasyncdependency closure. When FastAPI resolves this dependency, it queries theconversation_acltable 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, a403 Forbiddenresponse is returned immediately—the route handler never executes. This pattern centralizes authorization logic so that adding a new endpoint only requiresDepends(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
- ✓Do keep
SHARE_LINKas a separate table and generate itstokenwithsecrets.token_urlsafe— decoupling link-based sharing fromCONVERSATION_ACLrows means you can revoke a link by deleting oneSHARE_LINKrow without disturbing direct ACL grants, and a cryptographically random token ensures share URLs cannot be guessed or brute-forced even at scale. - ✓Do enforce
UniqueConstraint("conversation_id", "user_id", name="uq_conv_user")onConversationACL— concurrent share-link redemptions can race to insert two rows for the same user-conversation pair, producing non-deterministicaccess_levelreads; this constraint is the only guard that makes the "exactly one effective permission per user per conversation" invariant hold under load. - ✓Do set
expires_aton everyCONVERSATION_ACLrow 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
- ✗Don't route permission checks through
CONVERSATION.owner_idalone — every FastAPI dependency must queryCONVERSATION_ACLdirectly, becauseowner_idis blind to editor and viewer grants and cannot evaluate theexpires_atcolumn that gates time-bounded share links, leaving those permission paths completely unenforced. - ✗Don't allow the share endpoint to write
AccessLevel.OWNERgrants — ownership is explicitly non-transferable through the share path; accepting"owner"as a targetaccess_levelat the share endpoint lets any current owner duplicate ownership without an explicit transfer operation, breaking the single-owner invariant and corrupting thegranted_byaudit trail. - ✗Don't let
editor-level users call theCONVERSATION_ACLwrite path — theACCESS_HIERARCHYdict ranksEDITORat 2 andOWNERat 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
- Ch 9Build a WebSocket connection manager with JWT auth
- Ch 9Build presence tracking with Redis sorted sets
- Ch 9Build an event broadcast system with Redis pub/sub
- Ch 10Build JWT auth with refresh-token rotation and Redis sessions
- Ch 10Build conversation ownership + RBAC sharingYou are here
- Ch 10Build Llama Guard 4 content classifier
- Ch 11Build polymorphic file/attachment + embedding models