This document describes the conversation sharing system I've implemented for Ushadow, designed to integrate with Keycloak Fine-Grained Authorization (FGA) while remaining functional with the current JWT authentication system.
Since ushadow runs behind your private Tailscale network, external users cannot directly access it. The sharing system supports two modes:
- User sets
tailscale_only=trueon share link - Friend must join your Tailnet (temporarily or permanently)
- Friend accesses ushadow directly via Tailscale
- Most secure, zero trust
- User sets
tailscale_only=falseon share link - Share link points to public gateway:
https://share.yourdomain.com/c/{token} - Gateway validates token, proxies ONLY shared resource
- Gateway connects to ushadow via Tailscale (private connection)
- Friend never has direct access to your Tailnet
Gateway Architecture:
Public Internet
β
βββ Friend visits: https://share.yourdomain.com/c/550e8400-...
β
βΌ
Share Gateway (Public VPS, ~$5/month)
β - Validates share token
β - Rate limited (10 req/min per IP)
β - Audit logging
β - Only exposes /c/{token} endpoint
β
βΌ (via Tailscale)
Your Private Tailnet
βββ ushadow backend β Friend NEVER accesses directly
βββ MongoDB
βββ Your devices
Gateway Implementation: See share-gateway/ directory for complete deployment-ready code.
Models (ushadow/backend/src/models/share.py):
ShareToken- Beanie document for MongoDB storageShareTokenCreate- API request modelShareTokenResponse- API response modelKeycloakPolicy- Keycloak-compatible policy structure- Enums:
ResourceType,SharePermission
Service (ushadow/backend/src/services/share_service.py):
ShareService- Business logic for share management- Token creation/validation/revocation
- Audit logging for all access
- Keycloak integration stubs (ready for implementation)
API Router (ushadow/backend/src/routers/share.py):
POST /api/share/create- Create share tokenGET /api/share/{token}- Access shared resourceDELETE /api/share/{token}- Revoke shareGET /api/share/resource/{type}/{id}- List shares for resourceGET /api/share/{token}/logs- View access audit logs- Convenience endpoints:
/api/share/conversations/{id}
Components (ushadow/frontend/src/components/):
ShareDialog.tsx- Full-featured share management UI- Create share links with expiration/view limits
- List existing shares
- Copy links to clipboard
- Revoke access with confirmation
Hooks (ushadow/frontend/src/hooks/):
useShare.ts- Share dialog state management
Database: ShareToken collection added to Beanie initialization in main.py:
await init_beanie(database=db, document_models=[User, ShareToken])Router: Share router registered in main.py:
app.include_router(share.router, tags=["sharing"])I've intentionally left several business logic decisions for you to implement. These are marked with TODO comments in the code and represent strategic choices that should align with your security and UX requirements.
Location: ShareService._validate_resource_exists()
Current State: Placeholder that skips validation
Decision Point: How should we verify that a conversation/memory/resource exists before creating a share link?
async def _validate_resource_exists(
self,
resource_type: ResourceType,
resource_id: str,
):
"""Validate that resource exists and is accessible.
TODO: Implement resource validation
- For conversations: Check Chronicle API
- For memories: Check Mycelia API
- Raise ValueError if resource doesn't exist
"""Options:
- Strict: Call Chronicle/Mycelia API to verify resource exists
- Lazy: Assume resource exists, fail when accessed
- Cache-based: Check local cache/database first
Trade-offs:
- Strict validation prevents sharing non-existent resources but adds API latency
- Lazy validation is faster but could create broken share links
- Cache-based is fast but might be stale
Recommended Implementation:
# Example for conversations
if resource_type == ResourceType.CONVERSATION:
response = await httpx.get(
f"{CHRONICLE_URL}/conversations/{resource_id}",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 404:
raise ValueError(f"Conversation {resource_id} not found")Location: ShareService._validate_user_can_share()
Current State: Allows all authenticated users
Decision Point: Who should be allowed to share a resource?
async def _validate_user_can_share(
self,
user: User,
resource_type: ResourceType,
resource_id: str,
):
"""Validate user has permission to share resource.
TODO: DECISION POINT - Implement authorization check
Options:
1. Strict: Only resource owner can share
2. Permissive: Anyone with read access can share
3. Role-based: Only users with "share" permission can share
"""Options:
- Owner-only: Only the user who created the resource can share it
- Viewer-based: Anyone who can view the resource can share it
- Role-based: Check Keycloak roles/permissions
- Admin-only: Only superusers can create shares
Trade-offs:
- Owner-only is most secure but limits collaboration
- Viewer-based enables viral sharing but may leak sensitive data
- Role-based requires Keycloak integration
- Admin-only prevents user-driven sharing
Recommended Implementation:
# Option 1: Owner-only (strictest)
conversation = await get_conversation(resource_id)
if str(conversation.user_id) != str(user.id) and not user.is_superuser:
raise ValueError("Only the conversation owner can create share links")
# Option 2: Viewer-based (most permissive)
# If user can fetch the resource, they can share it
# (validation happens in _validate_resource_exists)
# Option 3: Role-based (Keycloak)
if not await keycloak.has_permission(user.id, resource_id, "share"):
raise ValueError("User lacks share permission for this resource")Location: ShareService._validate_tailscale_access()
Current State: Always returns True (allows all)
Decision Point: How should we verify requests are from your Tailscale network?
async def _validate_tailscale_access(self, request_ip: Optional[str]) -> bool:
"""Validate request is from Tailscale network.
TODO: DECISION POINT - Implement Tailscale validation
Options:
1. Check IP ranges (Tailscale CGNAT 100.64.0.0/10)
2. Validate via Tailscale API
3. Trust X-Forwarded-For from Tailscale reverse proxy
"""Options:
- IP Range Check: Verify IP is in Tailscale CGNAT range (100.64.0.0/10)
- Tailscale API: Call Tailscale API to verify device membership
- Reverse Proxy Headers: Trust
X-Tailscale-Userheader from Tailscale Serve - Mutual TLS: Validate client certificates
Trade-offs:
- IP range check is fast but can be spoofed if not behind Tailscale
- API validation is authoritative but adds latency
- Header trust is fast but requires secure reverse proxy setup
- mTLS is most secure but complex to set up
Recommended Implementation:
# Option 1: IP Range Check (simple, fast)
import ipaddress
if not request_ip:
return False
ip = ipaddress.ip_address(request_ip)
tailscale_range = ipaddress.ip_network("100.64.0.0/10")
return ip in tailscale_range
# Option 3: Header Trust (requires Tailscale Serve)
def get_tailscale_user(request: Request) -> Optional[str]:
return request.headers.get("X-Tailscale-User")
if share_token.tailscale_only and not get_tailscale_user(request):
return False, "Access restricted to Tailscale network"Location: ShareService._register_with_keycloak() and _unregister_from_keycloak()
Current State: Stub methods with debug logging
Decision Point: How should share tokens integrate with Keycloak Fine-Grained Authorization?
async def _register_with_keycloak(self, share_token: ShareToken):
"""Register share token with Keycloak FGA.
TODO: Implement Keycloak FGA registration
This should:
1. Create Keycloak resource for the shared item
2. Create Keycloak authorization policies
3. Store keycloak_policy_id and keycloak_resource_id on share_token
"""Implementation Steps:
-
Create Keycloak resource:
resource = await keycloak.create_resource( name=f"{share_token.resource_type}:{share_token.resource_id}", type=share_token.resource_type, owner=str(share_token.created_by) ) share_token.keycloak_resource_id = resource["_id"]
-
Create authorization policies:
for policy in share_token.policies: kc_policy = await keycloak.create_policy( name=f"share-{share_token.token}", resources=[resource["_id"]], scopes=[policy.action], logic="POSITIVE", decision_strategy="UNANIMOUS" ) share_token.keycloak_policy_id = kc_policy["id"]
-
Grant permissions to anonymous users (if
require_auth=False):if not share_token.require_auth: await keycloak.create_permission( name=f"anon-access-{share_token.token}", policy=kc_policy["id"], resources=[resource["_id"]], decision_strategy="AFFIRMATIVE" )
Libraries to Consider:
python-keycloak- Official Python clienthttpx- Direct REST API calls to Keycloak
Location: get_share_service() in share.py
Current State: Hardcoded to http://localhost:3000
Decision Point: How should the frontend URL be configured?
def get_share_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> ShareService:
# TODO: Get base_url from settings
base_url = "http://localhost:3000"
return ShareService(db=db, base_url=base_url)Options:
- Environment Variable:
FRONTEND_URLin.env - Settings File: Add to
config/config.defaults.yaml - Auto-detect: Use request.base_url from FastAPI
- Per-environment: Different URLs for dev/prod
Recommended Implementation:
from src.config.omegaconf_settings import get_settings
async def get_share_service(
db: AsyncIOMotorDatabase = Depends(get_database)
) -> ShareService:
settings = get_settings()
base_url = await settings.get(
"network.frontend_url",
default="http://localhost:3000"
)
return ShareService(db=db, base_url=base_url)import ShareDialog from '@/components/ShareDialog'
import { useShare } from '@/hooks/useShare'
import { Share2 } from 'lucide-react'
function ConversationView({ conversationId }: { conversationId: string }) {
const shareProps = useShare({
resourceType: 'conversation',
resourceId: conversationId
})
return (
<div>
<button
onClick={shareProps.openShareDialog}
data-testid="conversation-share-button"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded"
>
<Share2 className="w-4 h-4" />
Share
</button>
<ShareDialog
isOpen={shareProps.isShareDialogOpen}
onClose={shareProps.closeShareDialog}
resourceType={shareProps.resourceType}
resourceId={shareProps.resourceId}
/>
</div>
)
}# Public access (no auth required)
curl https://ushadow.example.com/api/share/550e8400-e29b-41d4-a716-446655440000
# Response
{
"share_token": {
"token": "550e8400-e29b-41d4-a716-446655440000",
"share_url": "https://ushadow.example.com/share/550e8400-...",
"permissions": ["read"],
"expires_at": "2026-02-08T14:35:00Z",
"view_count": 1
},
"resource": {
"type": "conversation",
"id": "conv_123",
"data": "Placeholder for conversation:conv_123"
}
}// From ShareDialog component
const revokeShareMutation = useMutation({
mutationFn: async (token: string) => {
const response = await fetch(`/api/share/${token}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) throw new Error('Failed to revoke')
}
})
await revokeShareMutation.mutateAsync(shareToken)- Expiration: Tokens can have TTL (expires_at)
- View Limits: Tokens can have max_views
- Authentication:
require_authflag enforces login - Network Restriction:
tailscale_onlylimits to your private network - Email Allowlist:
allowed_emailsrestricts to specific users - Audit Logging: Every access is logged with timestamp, user/IP, and metadata
{
"timestamp": "2026-02-01T15:30:00Z",
"user_identifier": "friend@example.com",
"action": "view",
"view_count": 3,
"metadata": {
"ip": "100.64.0.5",
"user_agent": "Mozilla/5.0..."
}
}- Create share link with expiration
- Create share link with view limit
- Create Tailscale-only share
- Create auth-required share
- Copy share link to clipboard
- Access share link (anonymous)
- Access share link (authenticated)
- Revoke share link
- View audit logs
- Share link expires correctly
- View limit enforced
# 1. Create share token
curl -X POST http://localhost:8080/api/share/create \
-H "Content-Type: application/json" \
-H "Cookie: ushadow_auth=YOUR_TOKEN" \
-d '{
"resource_type": "conversation",
"resource_id": "test_conv_123",
"permissions": ["read"],
"expires_in_days": 7,
"require_auth": false,
"tailscale_only": false
}'
# 2. Access share token (public)
curl http://localhost:8080/api/share/SHARE_TOKEN_UUID
# 3. List shares for resource
curl http://localhost:8080/api/share/resource/conversation/test_conv_123 \
-H "Cookie: ushadow_auth=YOUR_TOKEN"
# 4. Revoke share
curl -X DELETE http://localhost:8080/api/share/SHARE_TOKEN_UUID \
-H "Cookie: ushadow_auth=YOUR_TOKEN"-
Implement Decision Points (above)
- Resource validation
- Authorization checks
- Tailscale validation
- Keycloak integration
- Base URL configuration
-
Update Chronicle Integration
- Modify conversation routes to support share token access
- See section below for guidance
-
Frontend Integration
- Add share button to Chronicle conversation UI
- Import and use ShareDialog component
-
Production Configuration
- Set
FRONTEND_URLenvironment variable - Configure Keycloak if using FGA
- Set up Tailscale Serve if using network restriction
- Set
To allow shared conversations to be accessed via share tokens, you'll need to modify the Chronicle conversation routes.
File: chronicle/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py
Current State:
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str,
current_user: User = Depends(current_active_user)
):
# Check ownership
if not current_user.is_superuser and conversation.user_id != str(current_user.id):
raise HTTPException(403)Required Changes:
- Add optional share token parameter:
from typing import Optional, Union
from fastapi import Query
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str,
share_token: Optional[str] = Query(None), # Add this
current_user: Optional[User] = Depends(get_optional_current_user), # Make optional
):- Add share token validation:
# If share token provided, validate it
if share_token:
share_service = ShareService(db=db, base_url=BASE_URL)
is_valid, token_obj, reason = await share_service.validate_share_access(
token=share_token,
user_email=current_user.email if current_user else None,
request_ip=request.client.host if request.client else None
)
if not is_valid:
raise HTTPException(403, detail=reason)
# Verify token is for this conversation
if token_obj.resource_id != conversation_id:
raise HTTPException(403, detail="Share token not valid for this conversation")
# Record access
user_identifier = current_user.email if current_user else request.client.host
await share_service.record_share_access(
share_token=token_obj,
user_identifier=user_identifier,
action="view",
metadata={"user_agent": request.headers.get("user-agent")}
)
# Skip ownership check - share token grants access
else:
# Original ownership check
if not current_user:
raise HTTPException(401, detail="Authentication required")
if not current_user.is_superuser and conversation.user_id != str(current_user.id):
raise HTTPException(403, detail="Access denied"){
"_id": ObjectId("..."),
"token": "550e8400-e29b-41d4-a716-446655440000", # UUID, indexed
"resource_type": "conversation", # Indexed
"resource_id": "conv_123", # Indexed
"created_by": ObjectId("..."), # User who created
"policies": [
{
"resource": "conversation:conv_123",
"action": "read",
"effect": "allow"
}
],
"permissions": ["read"],
"require_auth": false,
"tailscale_only": false,
"allowed_emails": [],
"expires_at": ISODate("2026-02-08T14:35:00Z"),
"max_views": null,
"view_count": 5,
"last_accessed_at": ISODate("2026-02-01T15:30:00Z"),
"last_accessed_by": "friend@example.com",
"access_log": [
{
"timestamp": ISODate("2026-02-01T15:30:00Z"),
"user_identifier": "friend@example.com",
"action": "view",
"view_count": 5,
"metadata": {
"ip": "100.64.0.5",
"user_agent": "Mozilla/5.0..."
}
}
],
"keycloak_policy_id": null,
"keycloak_resource_id": null,
"created_at": ISODate("2026-02-01T14:35:00Z"),
"updated_at": ISODate("2026-02-01T15:30:00Z")
}token(unique)resource_typeresource_idcreated_byexpires_at- Compound:
(resource_type, resource_id)
The share token system uses KeycloakPolicy structures even though Keycloak isn't integrated yet because:
- Future-proof: When Keycloak FGA is added, migration is trivial
- Standards-based: Follows OAuth2/UMA patterns
- Mycelia-compatible: Matches existing policy structure in Mycelia
- Flexible: Supports both simple permissions and complex policies
Share tokens are independent of the user auth system because:
- Anonymous sharing: Users without accounts can access shares
- Revocation: Revoking a share doesn't affect user permissions
- Audit trail: Clear separation between user actions and share access
- Expiration: Shares can expire independently of user sessions
Cause: FastAPI app.state.db not set
Fix: Ensure main.py lifespan sets app.state.db = db
Cause: Router not registered
Fix: Verify app.include_router(share.router) in main.py
Cause: Token not in database or expired
Debug:
# In MongoDB shell
db.share_tokens.find({ token: "YOUR_TOKEN_UUID" })
# Check expiration
db.share_tokens.find({
token: "YOUR_TOKEN_UUID",
expires_at: { $gt: new Date() }
})Cause: CORS or auth cookies
Fix: Check middleware setup in main.py:
# CORS must allow credentials
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["http://localhost:3000"],
)You now have a complete sharing system with:
- β Backend models, service, and API
- β Frontend UI and hooks
- β Audit logging
- β Keycloak-ready architecture
- π Clear decision points for customization
The system is ready to use once you implement the 5 decision points marked with TODO comments. Start with resource validation and authorization, then add Tailscale/Keycloak integration as needed for your security requirements.