Authentication & Access#

This is the cross-cutting reference for who can reach a service and how they sign in. It explains the model once so the per-service docs don’t have to. For the app-by-app OIDC setup and gotchas, see Family Media; for the admin panes, Status Page.

The mental model#

There is no self-hosted identity provider here. Identity lives in Microsoft Entra ID (Azure AD); Cloudflare Access is the gate at the edge that authenticates against Entra and then decides, per service, who gets in. Access is over the public internet — Tailscale stays the admin-only backhaul, not the family path.

      person's browser
            │
            ▼
   ┌───────────────────────┐   1. authenticate: CF Access bounces the user to
   │  Cloudflare Access    │      Entra, gets back their identity + the Entra
   │  team: sc2in          │      security GROUPS they belong to (as claims).
   │  IdP: Entra ID        │
   └───────────┬───────────┘   2. authorize: the per-app Access POLICY says
               │                  "allow if the user is in group X". No group,
               │                  no entry — the app is never even reached.
               ▼
   ┌───────────────────────┐   3. pass identity DOWNSTREAM to the app, one of
   │  cloudflared tunnel   │      two ways (see below): a trusted header, or a
   │  → nginx loopback     │      full OIDC login with CF Access as the app's
   │  → the service        │      own IdP.
   └───────────────────────┘

The whole public surface is one attrset in Nix: tsunaminoai.cloudflare.origins (in hosts/x86_64-nixos/ereshkigal/default.nix). Terranix (modules/flake/terraform/cloudflare.nix) turns each origin into a DNS record, a Cloudflare Access application, an Allow policy, and (for OIDC apps) a SaaS/OIDC app — plus its App Launcher tile. A service with no origin is not on the internet: it stays Tailscale/LAN-only.

Groups → policies (the “grouping” part)#

Access is gated by Entra security group, not by individual user. Each origin picks one via accessGroup, which resolves to an Entra group Object ID in terraform:

accessGroup Entra group Who / what it gates
family (default) sg-voile-personal The family media tiles (Watch, Music, Audiobooks, Books, Ask)
observability sg-voile-personal-admins Admin panes (Grafana, ntfy / alerts)

The Access policy for an app is a single rule: allow identities whose Entra groups include this group. That is the entire authorization decision at the edge. To grant someone access to everything in a tier, you add them to that group in Entra — you never touch Cloudflare or this repo.

The group→ID mapping and the Entra IdP/team IDs are non-secret and baked into modules/flake/terraform/default.nix (sg-voile-personal, sg-voile-personal-admins, entraIdpId, teamDomain = sc2in.cloudflareaccess.com). The Cloudflare API token is the only secret, and it comes from sops (cloudflare/terraform-token).

Claims → the app (the “how do they sign in” part)#

Once Access has let someone through, the app still needs to know who they are. Two mechanisms, chosen by what the app supports:

1. Trusted header (simplest — no app-side login)#

CF Access injects Cf-Access-Authenticated-User-Email. The origin’s nginx vhost copies it into the header the app trusts (the identityHeader option), and the app auto-creates/looks-up that user. No login page, no OIDC, no per-app secret.

  • Used by Navidrome (Remote-User) and paperless-ask (X-Remote-User).
  • Requires the app to trust a reverse-proxy header from loopback only — the origins are 127.0.0.1-bound so the header can’t be forged from the network.
  • Downside: native (non-browser) clients can’t use it — e.g. a Subsonic app needs a real Navidrome password.

2. OIDC with Cloudflare Access as the IdP#

For apps that speak OIDC, terranix creates a CF Access SaaS/OIDC app and the app treats Cloudflare as its identity provider. Because the user already has an edge session, the app’s OIDC redirect is silent. The token can carry the Entra group claim, which the app can map to its own roles.

  • Used by Kavita, Audiobookshelf, Jellyfin (via jellyfin-plugin-sso), and Grafana (role-mapped).
  • Enable with oidc.enable = true (+ redirectURIs) on the origin. After apply, read the emitted client_id / client_secret / discovery_url and set them once in the app (they live in the app’s own DB — except Grafana, whose config is in Nix via a sops EnvironmentFile). Per-app steps and the role-claim / provisioning gotchas are in Family Media §5.

Group claims for role mapping (Grafana). Grafana maps the Entra group claim to a Grafana role (sg-voile-personal-admins → Admin, else Viewer) via oauthRoleAttributePath. For this to work the CF Access SaaS app must be configured to include group memberships in the OIDC token, and the JMESPath must match the token’s actual claim shape (groups[*].id). Landing as Viewer when you expect Admin almost always means one of those two is off.

Recipes#

Give a person access. Add them to the Entra security group for the tier: sg-voile-personal (family) or sg-voile-personal-admins (admin). SSO auto-provisions their app accounts on first login. Nothing to deploy. (Kavita needs its default Login role set once — see the gotchas.)

Remove access. Remove them from the group. The edge session stops validating; the app account can be left or pruned.

Gate a new service. Add one entry to tsunaminoai.cloudflare.origins:

myapp = {
  publicHostname = "myapp.inaba.network";
  originPort = 91NN;                 # loopback nginx vhost → the app's real port
  accessGroup = "family";            # or "observability" for an admin tool
  displayName = "My App";
  logo = "…";  tags = ["family"];    # App Launcher tile
  oidc.enable = true;                # only if the app speaks OIDC
  redirectURIs = [ "https://myapp.inaba.network/oauth/callback" ];
};

Then deploy (origin vhost + tunnel ingress) and nix run .#terraform-cloudflare -- apply (DNS + Access app + policy [+ OIDC app]). Header-only apps skip oidc; wire their trusted header via identityHeader instead.

Change who counts as an admin. Edit group membership in Entra (sg-voile-personal-admins). For Grafana specifically, the role mapping itself is in Nix (grafana.oauthRoleAttributePath).

Not the same thing: Entra device join#

shinobu is device/OS-joined to Entra via himmelblau (workstation login with an Entra account). That is Entra authenticating a login session on a machine — unrelated to the Cloudflare Access app SSO described here. The azure-entra synthetic check in the monitors is just an up/down probe of login.microsoftonline.com, not part of this flow either.

Verify#

# origin isolation — from a LAN host this must FAIL (loopback-only origins):
curl -k --connect-timeout 3 https://<ereshkigal-lan-ip>:91NN/ ; echo "expect: refused"

# forged identity header straight to a backend must NOT authenticate:
curl -s -H "Remote-User: mallory@example.com" http://127.0.0.1:<port>/ | head

# negative test in a browser: an Entra account NOT in the tier's group must hit
# the Cloudflare Access block page, never the app.