Per user projects with Authentik

This is essentially a continuation of my previous post about openfga group sync

I have expanded it so that it doesn’t just sync groups, but also creates projects automatically per user.

As a quick overview, Authentik, an IDP/Single Sign On software has a feature called “expression policies”, which are essentially arbitrary python code. They are supposed to be used to check conditions, and then return true/false depending on whether or not you want to allow an event to happen. But, by always returning true, you can use them as hooks that call to external software (via http requests) and do stuff.

The first piece of the magic is an expression policy that syncs openfga groups to authentik groups.

import json
from authentik.core.models import Group

system_groups = ["authentik Read-only", "authentik Admins"]

all_groups = [group.name for group in Group.objects.all()]

all_groups = list(set(all_groups) - set(system_groups))

user_groups = [group.name for group in request.user.all_groups()]

openfga_api_key = ""

auth_model = ""

store_id = ""

writes = {
    "writes": {
        "tuple_keys": [
            {
                "user": f"user:{request.user.username}",
                "relation": "member",
                "object": "group"
            }
        ]
    },
    "authorization_model_id": f"{auth_model}"
}

user_project_writes = {
    "writes": {
        "tuple_keys": [
            {
                "user": f"user:{request.user.username}",
                "relation": "operator",
                "object": f"project:{request.user.username}"
            }
        ]
    },
    "authorization_model_id": f"{auth_model}"
}

deletes = {
    "deletes": {
        "tuple_keys": [
            {
                "user": f"user:{request.user.username}",
                "relation": "member",
                "object": "group"
            }
        ]
    },
    "authorization_model_id": f"{auth_model}"
}

network_writes = {
    "writes": {
        "tuple_keys": [
            {
                "network": f"network:{request.user.username}-cloudnet",
                "relation": "project",
                "object": f"project:{request.user.username}"
            }
        ]
    },
    "authorization_model_id": f"{auth_model}"
}

vlab_writes = {
    "writes": {
        "tuple_keys": [
            {
                "network": f"network:{request.user.username}-vlab",
                "relation": "project",
                "object": f"project:{request.user.username}"
            }
        ]
    },
    "authorization_model_id": f"{auth_model}"
}


headers = {
    "Authorization": f"Bearer {openfga_api_key}",
    "content-type": "application/json"
}


for group in list(set(all_groups) - set(user_groups)):
  deletes["deletes"]["tuple_keys"][0]["object"] = f"group:{group}"
  requests.post(f"https://openfga.moonpiedumpl.ing/stores/{store_id}/write", data=json.dumps(deletes), headers=headers)


requests.post(f"https://openfga.moonpiedumpl.ing/stores/{store_id}/write", data=json.dumps(vlab_writes), headers=headers)

requests.post(f"https://openfga.moonpiedumpl.ing/stores/{store_id}/write", data=json.dumps(network_writes), headers=headers)

requests.post(f"https://openfga.moonpiedumpl.ing/stores/{store_id}/write", data=json.dumps(user_project_writes), headers=headers)

for group in user_groups:
    writes["writes"]["tuple_keys"][0]["object"] = f"group:{group}"
    requests.post(f"https://openfga.moonpiedumpl.ing/stores/{store_id}/write", data=json.dumps(writes), headers=headers)

return True

The second part of the magic is an authentik expression policy that creates Incus projects per user:

import requests
import tempfile
import os
import warnings

warnings.filterwarnings("ignore")

# ====== CONFIG ======
INCUS_API = "ip.ip.ip.ip:8443"

# Desired project state (used for create/update)
PROJECT_PAYLOAD = {
    "name": request.user.username,
    "description": "User specific project",
    "config": {
        "features.images": "false",
        "features.networks": "true",
        "features.storage.buckets": "true",
        "features.profiles": "true",
        "features.storage.volumes": "true",
        "limits.cpu": 4,
        "limits.memory": "8GiB",
         "restricted": "true",
         "restricted.containers.nesting": "allow",
         "restricted.backups": "block",
         "restricted.snapshots": "allow",
         "restricted.networks.uplinks": "forovn0"
        }
}


cert_path = "/incus-secrets/incus-crt"
key_path = "/incus-secrets/incus-key"

s = requests.Session()
s.verify = False 
s.cert = (cert_path, key_path)


# 1) Check if the project exists
get_url = f"{INCUS_API}/1.0/projects/{request.user.username}"
r = s.get(get_url)

if r.status_code == 200:
    # 2a) Exists -> PUT update
    put_url = get_url
    s.put(put_url, json=PROJECT_PAYLOAD)

elif r.status_code == 404:
    # 2b) Missing -> POST create
    post_url = f"{INCUS_API}/1.0/projects"
    s.post(post_url, json=PROJECT_PAYLOAD)


return True

To get the cert used, I mount it into the authentik server container. This is important because it’s the server container that executes expression policies. Here is a snippet of the helm release:

I did have to seperate the incus pfx cert out into two seperate files before mounting it in:

openssl pkcs12 -in incus-ui.pfx -nocerts -nodes -out client.key

openssl pkcs12 -in incus-ui.pfx -clcerts -nokeys -out client.crt

And then I put this into a kubernetes secret and mount it in via helm.

---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: authentik
  namespace: authentik
spec:
  chart:
    spec:
      chart: authentik
      reconcileStrategy: ChartVersion
      sourceRef:
        kind: HelmRepository
        name: authentik
  interval: 1m0s
  values:
    server:
      volumes:
        - name: incus-creds
          secret:
            secretName: incus-secrets
      volumeMounts:
        - name: incus-creds
          mountPath: /incus-secrets
    worker:
      volumes:
        - name: incus-creds
          secret:
            secretName: incus-secrets
      volumeMounts:
        - name: incus-creds
          mountPath: /incus-secrets
          readOnly: true
  

Of course it is different depending on if you are using helm and values directly, or docker or podman. But you should get the idea.

And with this, users automatically get their own private projects, with limited resources. Another benefit is that I can update the configs of these projects and make them apply again on login, so if I wanted to do things like retroactively allow access to the GPU, or increase/decrease resources, I could do that.

For networks, users can create their own private networks by using OVN and forovn0 as an uplink. I figured that one out in this post.

One thing I have done, to avoid a potential security issue, is to create and then lock Authentik users with names that have the same names as the existing projects. This prevents people from creating an account with a username of “Default” and then getting access to the “Default” Incus project.

2 Likes