Skip to content

Identity & Access

All internal services authenticate through Authentik, a self-hosted identity provider. This gives us single sign-on across all tools and centralized user management.

Architecture

flowchart TB
    Auth[Authentik<br/>auth.minnova.io]
    Auth --> HS[Headscale<br/>VPN]
    Auth --> GF[Grafana<br/>Dashboards]
    Auth --> Future[Future Apps]
    HS --> SSH[SSH Access]

Authentik

Authentik is an open-source identity provider similar to Okta or Auth0, but self-hosted. It runs on K3s (apps server) with:

  • PostgreSQL via CloudNative-PG operator (managed, with metrics)
  • Redis for session caching
  • Server + Worker deployments
  • Secrets managed via SOPS Secrets Operator

Exposed at auth.minnova.io through Cloudflare Tunnel → Traefik IngressRoute.

When you log into any integrated service (Headscale, Grafana, etc.), you're redirected to Authentik. After authenticating there, Authentik issues a token that proves your identity to the service. This means one login works everywhere, and we control all user data.

Authentik supports OIDC, OAuth2, and SAML, so it can integrate with almost any application that supports federated authentication. It also handles MFA through TOTP (authenticator apps) or WebAuthn (security keys).

Currently integrated with:

  • Headscale: When you run tailscale login, the browser opens Authentik for authentication. After login, Headscale grants VPN access based on your group membership.
  • Grafana: Uses proxy authentication through Cloudflare Access. Cloudflare validates your Authentik session and passes your email to Grafana, which auto-creates an account.
  • Cloudflare Access (Zero Trust): Self-hosted apps (grafana, portainer, homepage, argocd, nextcloud, traefik, gatus, oracle) use Cloudflare Access backed by the Authentik OIDC IdP (infra/live/cloudflare/zero_trust.tf).
  • Kimai: Uses SAML for authentication. Authentik groups map to Kimai roles (e.g., authentik AdminsROLE_SUPER_ADMIN). Config in infra/live/authentik/saml_providers.tf and infra/kubernetes/kimai/saml-config.yaml.

Google SSO

Authentik supports Google as a federated login source. A dedicated flow (google-sso-login) provides Google-only authentication without email/password.

  • Direct login URL: https://auth.minnova.io/source/oauth/login/google/
  • Links Google accounts to existing Authentik users by email
  • Default authentication flow remains available as fallback

User Management

User identity lives in multiple places depending on the use case:

What Where
Users, groups Authentik
SSH keys (emergency) Terraform
GitHub team membership Terraform
Access groups Terraform globals

Authentik is the primary identity store. Create users here for them to access internal services. Groups in Authentik control what services they can reach (via Headscale ACLs).

Terraform globals (infra/globals/access-groups.tf) maintains a canonical list of users with their email addresses and GitHub usernames. This is shared across configurations - when you add someone here, they automatically get added to GitHub teams, email routing, and other Terraform-managed resources.

SSH keys in Terraform are a backup mechanism. If Authentik goes down, we'd lose VPN access and couldn't SSH anywhere. Emergency SSH keys are baked into server images so there's always a way to recover. These should only be used when Authentik is unavailable.

Access Control

Access is controlled at two layers:

Layer Controls Managed in
Who can connect Group membership Authentik
What they can access ACL rules Headscale

Authentik groups determine if someone can authenticate at all. If a user isn't in the right group, Headscale won't grant them VPN access even with valid credentials.

Headscale ACLs determine what authenticated users can reach. You might be on the VPN but only allowed to access certain servers. ACLs are defined in infra/ansible/files/headscale-acl.json and reference Authentik groups.

To grant someone full access:

  1. Create their user in Authentik
  2. Add them to the "founders" group (or equivalent)
  3. They run tailscale login --login-server https://headscale.minnova.io
  4. Browser opens, they authenticate with Authentik
  5. Headscale checks their group membership, applies ACLs
  6. They can now SSH to bastion (100.64.0.6), then to internal servers

Secrets Management

Infrastructure secrets are encrypted with SOPS using Age keys. This lets us store secrets in Git alongside the code that uses them, while keeping the actual values encrypted.

Ansible Secrets

For Ansible-managed resources, secrets live in infra/ansible/secrets/. SOPS encrypts only values, leaving keys readable:

# What you see in Git (encrypted)
database_password: ENC[AES256_GCM,data:abc123...,tag:xyz...]

To edit: sops infra/ansible/secrets/cloudflared.yaml

Kubernetes Secrets

For K8s, we use the SOPS Secrets Operator. Secrets live in infra/kubernetes/*/secrets/ as SopsSecret CRDs:

apiVersion: isindir.github.com/v1alpha3
kind: SopsSecret
metadata:
  name: my-secrets
spec:
  secretTemplates:
    - name: my-k8s-secret
      stringData:
        password: ENC[AES256_GCM,data:abc123...]

The operator runs in the sops namespace with the server's Age key mounted. It watches for SopsSecret resources, decrypts them, and creates regular K8s Secrets.

Key Management

The .sops.yaml config defines which Age keys can decrypt which paths:

  • Developer keys - for local editing
  • Server key (apps_host) - for runtime decryption on server

Both keys must be in .sops.yaml for secrets that need to be read by both. Run make sops.updatekeys after adding new keys.

Future plan: migrate from Age to AWS KMS for key rotation and IAM-based access control.