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:
| Primitive | What it already does | How this architecture uses it |
|---|---|---|
| Cloudflare Access | Edge login, identity provider routing, JWT/headers, policy gates | Public login wall, apply gate, per-app coarse policy |
| Tailscale | Private network identity, device/user presence, Serve HTTPS | Tailnet-only private-data reveal and private service links |
| Overseerr | Request portal for Plex, Radarr, Sonarr | Friend-facing movie/TV request UI after approval |
| Plex | App-native user/library sharing | Final library access for video |
| Audiobookshelf | App-native users and libraries | Final audiobook/podcast access |
| Komga/Kavita | Reading libraries | Optional reading-tier access |
| Stash/Whisparr | Sensitive/adult stack | Explicit adult-private tier only; never included in generic media grants |
| Tautulli | Plex activity observability | Activity evidence, not an authorization source |
| Uptime Kuma / Netdata | Service health | Portal status cards, not authorization |
tools/current-state.sh --json | Live system inventory | Portal/admin telemetry source; avoids stale session docs |
Reference graph
Core decision
Do not use one boolean like is_tailnet_member or is_friend. That will leak too much. Use five planes:
- Identity plane: Cloudflare Access verifies who is at the browser.
- Admissions plane: an application record explains why they want access and stores your notes.
- Entitlement plane: the source of truth for person -> service grants.
- Network plane: Tailscale proves the person/device can reach private data, but does not imply every service grant.
- 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:
- Cloudflare Access applications: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/
- Cloudflare identity providers: https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/
- Cloudflare One-time PIN: https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/
- Cloudflare Access policies: https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
- Cloudflare Access JWT validation: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
- Cloudflare AI Gateway DLP: https://developers.cloudflare.com/ai-gateway/features/dlp/
- Tailscale sharing: https://tailscale.com/docs/features/sharing
- Tailscale user approval: https://tailscale.com/docs/features/access-control/user-approval/
- Tailscale device approval: https://tailscale.com/docs/features/access-control/device-management/device-approval/
- Tailscale Serve identity headers and HTTPS serving: https://tailscale.com/docs/features/tailscale-serve
- Overseerr archived upstream: https://github.com/sct/overseerr
- Seerr successor project: https://seerr.dev/
Entitlement matrix
Recommended groups:
| Group | Grants | Explicit non-grants |
|---|---|---|
apply-only | Landing, application status | Everything else |
family-basic | Plex, Audiobookshelf, landing docs | Stash, Whisparr, admin |
media-requesters | Overseerr request role, quota-limited | Radarr/Sonarr/Prowlarr direct admin |
reading-users | Audiobookshelf, Komga/Kavita where desired | Stash, Whisparr, admin |
adult-private | Stash and adult-specific docs | Never implied by any other group |
ops-admins | Portainer, Netdata, Uptime Kuma, Arr admin, SABnzbd | Human operators only |
Concrete example:
| Person | Initial grants |
|---|---|
| Mom | family-basic; maybe media-requesters; no adult-private; no ops-admins |
| Trusted friend | media-requesters; Plex library share; maybe reading-users |
| Reading-only friend | reading-users; no video request portal unless explicitly granted |
| Unknown visitor | apply-only |
Lifecycle
State transitions
| State | Entry condition | Allowed next states |
|---|---|---|
visitor | no identity | logged_in |
logged_in | Cloudflare identity verified | applied, active if already approved |
applied | application submitted | review_queue, needs_more_info, denied |
review_queue | owner has enough context | provisioned, denied, needs_more_info |
provisioned | grants created and target systems updated | active, revoked |
active | user has current grants | review_queue, revoked |
revoked | grant removed | terminal 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:
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:
- Enable Overseerr notifications/webhooks for request events; current settings should be treated as owner-local until notifications are configured.
- Fix old request-root drift if still present in the DB: historical rows used
/pool/anime; current service roots should use/pool/anime-moviesand/pool/anime-tv. - Keep request users out of Radarr/Sonarr/Prowlarr directly. Overseerr is the controlled public interface.
- 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
| Service | Recommended exposure | Enforcement layer |
|---|---|---|
| Landing/apply | Public through Cloudflare Access | Cloudflare identity + app session |
| Plex | App-native invite plus optional portal link | Plex library shares |
| Audiobookshelf | Tailnet or Access-fronted, app-native login | Audiobookshelf users/libraries |
| Overseerr | Access-fronted or tailnet, requester role | Overseerr roles, quotas, request approvals |
| Komga/Kavita | Tailnet or Access-fronted | App-native users/libraries where supported |
| Stash | Tailnet-only, separate hostname, no public nav | adult-private group + app auth |
| Whisparr | Not friend-facing | Owner-only admin |
| Radarr/Sonarr/Prowlarr/SABnzbd | Not friend-facing | Owner-only admin |
| Portainer/Netdata/Uptime Kuma | Owner/admin only | Cloudflare/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.netapp 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
#3eaf7cfromdocs/.vitepress/config.js; - gradients from
docs/.vitepress/theme/tools.cssand 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/path | Reuse |
|---|---|
tools/current-state.sh --json | Portal service status and audit snapshots |
tools/sysinfo-snapshot | Admin health card if portal needs host telemetry |
tools/metrics-collector | Historical performance/availability panels |
docs/.vitepress/components/*Topology.vue | Design source for future interactive topology |
docs/memory-spec.md | Event/note graph discipline and drift protection ideas |
docs/arr-stack-wiring.md | API integration pattern for Arr health and queues |
dot_claude/agents/media-agent.md | Future media request automation persona, not an auth primitive |
dot_claude/agents/docs-agent.md | Documentation synchronization/pruning discipline |
LLM-wiki contract
This is the compact source-of-truth card agents should preserve when summarizing or pruning this topic:
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.mdIf 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:
- Unknown visitor can log in and apply, but sees no media links.
- Approved friend who is not on Tailscale sees public instructions only.
- Approved friend on Tailscale sees only granted private links.
- Mom sees Plex and Audiobookshelf; she does not see Stash, Whisparr, Radarr, Sonarr, Prowlarr, SABnzbd, Portainer, or Netdata.
adult-privateuser can see Stash only after explicit grant and app-native auth also succeeds.- Revoking a person removes Cloudflare access, Tailscale access, Plex shares, Audiobookshelf libraries, Overseerr requester access, and sensitive-service accounts.
- 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.