Skip to content

Friends Access & Entitlements Architecture

Answer: build this as an identity + admissions + entitlement system, not as "who is on the tailnet." Cloudflare Access should front the public login and application flow. Tailscale should prove private-network presence and serve private facts. A small entitlement ledger should decide which links and service accounts exist. Plex, Audiobookshelf, Overseerr, Komga, Stash, and admin tools still enforce their own final permissions.

This gives you the thing you described:

  • friends can log in with Google, Apple/OIDC, GitHub/social, or email OTP;
  • unknown people can apply from the same landing page;
  • you can approve, deny, ask for context, and keep notes;
  • approved people see only the services they are entitled to;
  • tailnet-only data appears only when the user is also on Tailscale;
  • Mom can get Plex and Audiobookshelf without getting Stash, Whisparr, or admin;
  • revocation is explicit and auditable across Cloudflare, Tailscale, and apps.

Current-stack evidence

As of 2026-06-26T21:49:01-06:00, ./tools/current-state.sh --json reported 35 running containers, including plex, audiobookshelf, overseerr, komga, kavita, stash, whisparr, tautulli, uptime-kuma, radarr, sonarr, prowlarr, sabnzbd-portable, and transmission-portable.

The useful already-existing primitives are:

PrimitiveWhat it already doesHow this architecture uses it
Cloudflare AccessEdge login, identity provider routing, JWT/headers, policy gatesPublic login wall, apply gate, per-app coarse policy
TailscalePrivate network identity, device/user presence, Serve HTTPSTailnet-only private-data reveal and private service links
OverseerrRequest portal for Plex, Radarr, SonarrFriend-facing movie/TV request UI after approval
PlexApp-native user/library sharingFinal library access for video
AudiobookshelfApp-native users and librariesFinal audiobook/podcast access
Komga/KavitaReading librariesOptional reading-tier access
Stash/WhisparrSensitive/adult stackExplicit adult-private tier only; never included in generic media grants
TautulliPlex activity observabilityActivity evidence, not an authorization source
Uptime Kuma / NetdataService healthPortal status cards, not authorization
tools/current-state.sh --jsonLive system inventoryPortal/admin telemetry source; avoids stale session docs

Reference graph

Friends access architecture

Core decision

Do not use one boolean like is_tailnet_member or is_friend. That will leak too much. Use five planes:

  1. Identity plane: Cloudflare Access verifies who is at the browser.
  2. Admissions plane: an application record explains why they want access and stores your notes.
  3. Entitlement plane: the source of truth for person -> service grants.
  4. Network plane: Tailscale proves the person/device can reach private data, but does not imply every service grant.
  5. Application plane: Plex/Audiobookshelf/Overseerr/Komga/Stash enforce their own accounts, libraries, and roles.

The landing page renders from the entitlement API. It does not hide sensitive links with CSS. If the backend says the person lacks adult-private, Stash is not present in the response.

External product fit

Cloudflare does not appear to provide a general-purpose arbitrary-text PII scrubbing API for your own app logs. It provides useful guardrails around Zero Trust DLP, AI Gateway DLP, policy enforcement, payload masking, and log redaction inside Cloudflare-controlled flows. For this portal, keep your own PII boundary: store only what you need, redact notes before sending logs to agents, and use Cloudflare DLP as a defense-in-depth layer rather than the source of truth.

Cloudflare Access is the right public gate. Tailscale is the right private presence gate. The entitlement ledger is the missing piece.

Official references:

Entitlement matrix

Entitlement matrix by persona and service

Recommended groups:

GroupGrantsExplicit non-grants
apply-onlyLanding, application statusEverything else
family-basicPlex, Audiobookshelf, landing docsStash, Whisparr, admin
media-requestersOverseerr request role, quota-limitedRadarr/Sonarr/Prowlarr direct admin
reading-usersAudiobookshelf, Komga/Kavita where desiredStash, Whisparr, admin
adult-privateStash and adult-specific docsNever implied by any other group
ops-adminsPortainer, Netdata, Uptime Kuma, Arr admin, SABnzbdHuman operators only

Concrete example:

PersonInitial grants
Momfamily-basic; maybe media-requesters; no adult-private; no ops-admins
Trusted friendmedia-requesters; Plex library share; maybe reading-users
Reading-only friendreading-users; no video request portal unless explicitly granted
Unknown visitorapply-only

Lifecycle

Admission and provisioning lifecycle

State transitions

StateEntry conditionAllowed next states
visitorno identitylogged_in
logged_inCloudflare identity verifiedapplied, active if already approved
appliedapplication submittedreview_queue, needs_more_info, denied
review_queueowner has enough contextprovisioned, denied, needs_more_info
provisionedgrants created and target systems updatedactive, revoked
activeuser has current grantsreview_queue, revoked
revokedgrant removedterminal unless re-applied

Every transition writes an event. Notes are owner-visible. PII should not be sprayed into logs, traces, LLM prompts, or Discord-style notifications.

Data model

Minimum viable schema:

sql
people(
  id,
  display_name,
  relationship_label,
  owner_notes,
  created_at,
  last_reviewed_at
)

identities(
  id,
  person_id,
  provider,          -- google, apple_oidc, github, email_otp, tailscale
  provider_subject,
  email,
  verified_at,
  last_seen_at
)

applications(
  id,
  person_id,
  requested_groups,  -- json array
  requested_reason,
  referral,
  status,            -- applied, needs_more_info, approved, denied, revoked
  submitted_at,
  decided_at,
  decided_by
)

entitlements(
  id,
  person_id,
  group_slug,
  service_slug,
  scope,             -- library, role, path, app, quota
  status,            -- active, paused, revoked, expired
  starts_at,
  expires_at,
  approved_by
)

provisioning_targets(
  id,
  entitlement_id,
  target,            -- cloudflare, tailscale, plex, audiobookshelf, overseerr
  external_id,
  last_sync_status,
  last_sync_at
)

events(
  id,
  person_id,
  actor,
  action,
  target,
  redacted_summary,
  created_at
)

Deliberate omission: do not store provider refresh tokens unless a concrete sync job requires them. Prefer provider-side groups or one-shot provisioning tokens.

Request and media-flow integration

Overseerr is useful, but it is not the whole access system.

Correct role for Overseerr:

  • friend-facing request portal for movie/TV;
  • authenticated by its own account system or fronted by Cloudflare Access;
  • connected to Plex, Radarr, and Sonarr;
  • approval/quotas live there for media requests.

Wrong role for Overseerr:

  • global friend CRM;
  • source of truth for tailnet membership;
  • source of truth for Audiobookshelf, Komga, Stash, or admin access;
  • replacement for Cloudflare/Tailscale identity.

Current useful follow-ups for this stack:

  1. Enable Overseerr notifications/webhooks for request events; current settings should be treated as owner-local until notifications are configured.
  2. Fix old request-root drift if still present in the DB: historical rows used /pool/anime; current service roots should use /pool/anime-movies and /pool/anime-tv.
  3. Keep request users out of Radarr/Sonarr/Prowlarr directly. Overseerr is the controlled public interface.
  4. Prefer Seerr for future maintenance if Overseerr archival status becomes a blocker.

Portal behavior

Public page

The public page exists at a normal HTTPS hostname and is protected by Cloudflare Access. It shows:

  • identity greeting;
  • application status;
  • services this identity can request;
  • instructions for joining Tailscale if tailnet-only grants exist;
  • no raw private service URLs unless the private Tailscale API confirms access.

Tailnet reveal

The page attempts a fetch to a Tailscale Serve HTTPS endpoint. If it fails, the page says: "You are approved, but this private link only appears when your approved device is on Tailscale." If it succeeds, the backend matches:

  • Cloudflare identity email/provider subject;
  • Tailscale user/device identity;
  • active entitlements.

Only then does it return private service links and per-service instructions.

Admin page

Owner-only page:

  • pending applications;
  • person cards with relationship notes;
  • grant/revoke buttons;
  • provisioning status per target;
  • event log;
  • stale review queue: "this person has not been reviewed in 180 days";
  • PII-safe export for agents.

Enforcement details by service

ServiceRecommended exposureEnforcement layer
Landing/applyPublic through Cloudflare AccessCloudflare identity + app session
PlexApp-native invite plus optional portal linkPlex library shares
AudiobookshelfTailnet or Access-fronted, app-native loginAudiobookshelf users/libraries
OverseerrAccess-fronted or tailnet, requester roleOverseerr roles, quotas, request approvals
Komga/KavitaTailnet or Access-frontedApp-native users/libraries where supported
StashTailnet-only, separate hostname, no public navadult-private group + app auth
WhisparrNot friend-facingOwner-only admin
Radarr/Sonarr/Prowlarr/SABnzbdNot friend-facingOwner-only admin
Portainer/Netdata/Uptime KumaOwner/admin onlyCloudflare/Tailscale + app auth

Build plan

Phase 0: paper/Notion mode

  • Create the entitlement matrix manually.
  • Write person records and service grants in a small table.
  • Confirm Mom/friend/stash scenarios by hand before automating.

Phase 1: Cloudflare Access + apply app

  • Create friends.beppesarrstack.net app behind Cloudflare Access.
  • Enable Google and email OTP first; add Apple/OIDC when credentials are ready.
  • Validate Cloudflare JWT in the app.
  • Store applications, notes, and entitlement decisions.

Phase 2: tailnet reveal

  • Serve a private API through Tailscale Serve.
  • Return private links only when Tailscale identity and entitlement ledger match.
  • Add clear user messaging for "approved but not on Tailscale."

Phase 3: provisioning automation

  • Cloudflare: assign groups/policies.
  • Tailscale: invite/share/approval workflow; avoid over-broad ACLs.
  • Plex: library share/invite.
  • Audiobookshelf: create user and assign libraries.
  • Overseerr: create/requester role and quota.
  • Stash: manual approval first; automate only after policy is proven.

Phase 4: observability and review

  • Pull Plex activity from Tautulli.
  • Pull service health from Uptime Kuma/current-state script.
  • Add event export that redacts PII before sending to LLM agents.
  • Add recurring review: stale users, expired grants, unused accounts.

Design-system notes

This doc uses the local VitePress design language already in the repo:

  • brand green #3eaf7c from docs/.vitepress/config.js;
  • gradients from docs/.vitepress/theme/tools.css and existing visualization components: #667eea -> #764ba2, #f093fb -> #f5576c, #4facfe -> #00f2fe, #50fa7b -> #8be9fd;
  • Inter/system font stack used by the existing Vue visualizations;
  • static SVGs under docs/public/images/generated/ so the docs build does not need to re-enable the disabled interactive Vue components.

The disabled Vue visualization components are still valuable as design source, but this page avoids depending on them because docs/.vitepress/theme/index.js currently says custom components are disabled for deployment stability.

Tooling to reuse

Tool/pathReuse
tools/current-state.sh --jsonPortal service status and audit snapshots
tools/sysinfo-snapshotAdmin health card if portal needs host telemetry
tools/metrics-collectorHistorical performance/availability panels
docs/.vitepress/components/*Topology.vueDesign source for future interactive topology
docs/memory-spec.mdEvent/note graph discipline and drift protection ideas
docs/arr-stack-wiring.mdAPI integration pattern for Arr health and queues
dot_claude/agents/media-agent.mdFuture media request automation persona, not an auth primitive
dot_claude/agents/docs-agent.mdDocumentation synchronization/pruning discipline

LLM-wiki contract

This is the compact source-of-truth card agents should preserve when summarizing or pruning this topic:

yaml
id: friends-access-entitlements
status: proposed
source_of_truth: docs/architecture/friends-access-entitlements.md
verified_at: 2026-06-27
owns:
  - friend login and admissions architecture
  - per-person service entitlement model
  - Tailscale-private data reveal pattern
must_not_own:
  - Cloudflare product documentation
  - Tailscale product documentation
  - app-specific admin manuals
invariants:
  - tailnet membership is not a service entitlement
  - Stash and Whisparr are never granted by a generic media group
  - landing page renders from backend entitlements, not hidden CSS links
  - revocation removes Cloudflare, Tailscale, and app-native access
related:
  - docs/architecture/tailscale-remote-access.md
  - docs/advanced/api-integration.md
  - docs/stack-usage-guide.md
  - docs/arr-stack-wiring.md

If this repo later gets a proper wiki/SCHEMA.md, promote that card into wiki/reference/friends-access-entitlements.md and add it to the wiki index. Do not bootstrap the wiki schema as part of this feature unless explicitly requested.

Acceptance tests

Before shipping the real implementation, these must pass:

  1. Unknown visitor can log in and apply, but sees no media links.
  2. Approved friend who is not on Tailscale sees public instructions only.
  3. Approved friend on Tailscale sees only granted private links.
  4. Mom sees Plex and Audiobookshelf; she does not see Stash, Whisparr, Radarr, Sonarr, Prowlarr, SABnzbd, Portainer, or Netdata.
  5. adult-private user can see Stash only after explicit grant and app-native auth also succeeds.
  6. Revoking a person removes Cloudflare access, Tailscale access, Plex shares, Audiobookshelf libraries, Overseerr requester access, and sensitive-service accounts.
  7. Agent/log exports redact email addresses, notes, and provider identifiers unless the owner explicitly requests a private audit.

Riskiest assumptions

  • Cloudflare Access identity provider configuration is not finished yet.
  • Apple login will likely require extra OIDC setup; start with Google and OTP.
  • Tailscale API automation may be more dangerous than manual approval at first; do manual approval until revoke semantics are tested.
  • Overseerr is already useful, but its archived upstream status means Seerr should be watched before investing heavily in custom plugins.
  • App-native permissions differ by service; the entitlement ledger cannot skip target-app verification.

Built with ❤️ following Bell Labs standards. Dedicated to Stan Eisenstat.