Add scoped LLDAP identity provisioner
This commit is contained in:
parent
86eed20012
commit
ba07dd2acb
7 changed files with 377 additions and 0 deletions
7
identity-provisioner/Containerfile
Normal file
7
identity-provisioner/Containerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
|
||||||
|
RUN useradd --create-home --uid 10001 provisioner
|
||||||
|
WORKDIR /app
|
||||||
|
COPY provisioner.py server.py /app/
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["python", "server.py"]
|
||||||
18
identity-provisioner/copy-client-secret.py
Normal file
18
identity-provisioner/copy-client-secret.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Copy only the provisioner token into a namespace-local client Secret."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
source = json.load(sys.stdin)
|
||||||
|
target = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": {
|
||||||
|
"name": "identity-provisioner-client",
|
||||||
|
"namespace": "user-engine",
|
||||||
|
},
|
||||||
|
"type": "Opaque",
|
||||||
|
"data": {"token": source["data"]["token"]},
|
||||||
|
}
|
||||||
|
json.dump(target, sys.stdout)
|
||||||
155
identity-provisioner/provisioner.py
Normal file
155
identity-provisioner/provisioner.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""NetKingdom's idempotent LLDAP lifecycle adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Result:
|
||||||
|
provider: str
|
||||||
|
external_subject: str
|
||||||
|
status: str
|
||||||
|
resumed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LLDAPProvisioner:
|
||||||
|
def __init__(self, *, base_url: str, admin_password: str, opener: Callable = urlopen) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.admin_password = admin_password
|
||||||
|
self.opener = opener
|
||||||
|
|
||||||
|
def provision(self, payload: dict[str, Any]) -> Result:
|
||||||
|
_required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id")
|
||||||
|
email = str(payload["primary_email"]).strip().lower()
|
||||||
|
username = _username(email)
|
||||||
|
token = self._login()
|
||||||
|
users, groups = self._directory(token)
|
||||||
|
existing = next((user for user in users if user.get("id") == username), None)
|
||||||
|
resumed = existing is not None
|
||||||
|
if existing is None:
|
||||||
|
self._gql(token, """
|
||||||
|
mutation CreateUser($id: String!, $email: String!, $display: String!) {
|
||||||
|
createUser(user: {id: $id, email: $email, displayName: $display}) { id }
|
||||||
|
}""", {
|
||||||
|
"id": username,
|
||||||
|
"email": email,
|
||||||
|
"display": str(payload.get("display_name") or username),
|
||||||
|
})
|
||||||
|
elif str(existing.get("email", "")).lower() != email:
|
||||||
|
raise ValueError("directory username collision")
|
||||||
|
roles = {str(role) for role in payload.get("roles", ())}
|
||||||
|
group_names = [f"{payload['tenant']}:users"]
|
||||||
|
if "tenant-admin" in roles:
|
||||||
|
group_names.append(f"{payload['tenant']}:admins")
|
||||||
|
for name in group_names:
|
||||||
|
group_id = self._ensure_group(token, groups, name)
|
||||||
|
self._add_group(token, username, group_id)
|
||||||
|
return Result("netkingdom-lldap", username, "password_setup_required", resumed)
|
||||||
|
|
||||||
|
def suspend(self, subject: str) -> Result:
|
||||||
|
token = self._login()
|
||||||
|
_, groups = self._directory(token)
|
||||||
|
group_id = self._ensure_group(token, groups, "netkingdom-suspended")
|
||||||
|
self._add_group(token, subject, group_id)
|
||||||
|
return Result("netkingdom-lldap", subject, "suspended", False)
|
||||||
|
|
||||||
|
def reactivate(self, subject: str) -> Result:
|
||||||
|
token = self._login()
|
||||||
|
_, groups = self._directory(token)
|
||||||
|
group = next((item for item in groups if item.get("displayName") == "netkingdom-suspended"), None)
|
||||||
|
if group:
|
||||||
|
self._gql(token, """
|
||||||
|
mutation Remove($userId: String!, $groupId: Int!) {
|
||||||
|
removeUserFromGroup(userId: $userId, groupId: $groupId) { ok }
|
||||||
|
}""", {"userId": subject, "groupId": int(group["id"])})
|
||||||
|
return Result("netkingdom-lldap", subject, "active", False)
|
||||||
|
|
||||||
|
def deprovision(self, subject: str) -> Result:
|
||||||
|
token = self._login()
|
||||||
|
self._gql(token, "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", {"id": subject})
|
||||||
|
return Result("netkingdom-lldap", subject, "deprovisioned", False)
|
||||||
|
|
||||||
|
def _login(self) -> str:
|
||||||
|
request = Request(
|
||||||
|
self.base_url + "/auth/simple/login",
|
||||||
|
data=json.dumps({"username": "admin", "password": self.admin_password}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with self.opener(request, timeout=10) as response:
|
||||||
|
return str(json.loads(response.read())["token"])
|
||||||
|
|
||||||
|
def _directory(self, token: str) -> tuple[list[dict], list[dict]]:
|
||||||
|
value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {})
|
||||||
|
return list(value["users"]), list(value["groups"])
|
||||||
|
|
||||||
|
def _ensure_group(self, token: str, groups: list[dict], name: str) -> int:
|
||||||
|
existing = next((group for group in groups if group.get("displayName") == name), None)
|
||||||
|
if existing:
|
||||||
|
return int(existing["id"])
|
||||||
|
created = self._gql(
|
||||||
|
token,
|
||||||
|
"mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }",
|
||||||
|
{"name": name},
|
||||||
|
)["createGroup"]
|
||||||
|
groups.append(created)
|
||||||
|
return int(created["id"])
|
||||||
|
|
||||||
|
def _add_group(self, token: str, username: str, group_id: int) -> None:
|
||||||
|
try:
|
||||||
|
self._gql(token, """
|
||||||
|
mutation Add($userId: String!, $groupId: Int!) {
|
||||||
|
addUserToGroup(userId: $userId, groupId: $groupId) { ok }
|
||||||
|
}""", {"userId": username, "groupId": group_id})
|
||||||
|
except ValueError as exc:
|
||||||
|
if "already" not in str(exc).lower() and "unique" not in str(exc).lower():
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _gql(self, token: str, query: str, variables: dict[str, Any]) -> dict:
|
||||||
|
request = Request(
|
||||||
|
self.base_url + "/api/graphql",
|
||||||
|
data=json.dumps({"query": query, "variables": variables}).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with self.opener(request, timeout=15) as response:
|
||||||
|
payload = json.loads(response.read())
|
||||||
|
if payload.get("errors"):
|
||||||
|
raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error")))
|
||||||
|
return dict(payload.get("data") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any]) -> Result:
|
||||||
|
if path == "/v1/identities/provision":
|
||||||
|
return provisioner.provision(payload)
|
||||||
|
_required(payload, "external_subject", "idempotency_key", "correlation_id")
|
||||||
|
subject = str(payload["external_subject"])
|
||||||
|
if path == "/v1/identities/suspend":
|
||||||
|
return provisioner.suspend(subject)
|
||||||
|
if path == "/v1/identities/reactivate":
|
||||||
|
return provisioner.reactivate(subject)
|
||||||
|
if path == "/v1/identities/deprovision":
|
||||||
|
return provisioner.deprovision(subject)
|
||||||
|
raise KeyError(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _username(email: str) -> str:
|
||||||
|
local = email.partition("@")[0].lower()
|
||||||
|
value = re.sub(r"[^a-z0-9._-]+", "-", local).strip("-")
|
||||||
|
if not value or "@" not in email:
|
||||||
|
raise ValueError("valid primary_email is required")
|
||||||
|
return value[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def _required(payload: dict[str, Any], *fields: str) -> None:
|
||||||
|
missing = [field for field in fields if not payload.get(field)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("missing required fields: " + ", ".join(missing))
|
||||||
|
if len(str(payload.get("idempotency_key", ""))) < 16:
|
||||||
|
raise ValueError("idempotency_key must contain at least 16 characters")
|
||||||
56
identity-provisioner/server.py
Normal file
56
identity-provisioner/server.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from dataclasses import asdict
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from provisioner import LLDAPProvisioner, dispatch
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
provisioner: LLDAPProvisioner
|
||||||
|
service_token: str
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/healthz":
|
||||||
|
return self._send(200, {"status": "ok"})
|
||||||
|
self._send(404, {"error": "not_found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
supplied = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
|
if not secrets.compare_digest(supplied, self.service_token):
|
||||||
|
return self._send(403, {"error": "access_denied"})
|
||||||
|
try:
|
||||||
|
length = min(int(self.headers.get("Content-Length", "0")), 65536)
|
||||||
|
payload = json.loads(self.rfile.read(length))
|
||||||
|
result = dispatch(self.provisioner, self.path, payload)
|
||||||
|
except KeyError:
|
||||||
|
return self._send(404, {"error": "not_found"})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return self._send(400, {"error": "invalid_request", "message": str(exc)})
|
||||||
|
self._send(200, asdict(result))
|
||||||
|
|
||||||
|
def _send(self, status: int, payload: dict):
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
Handler.provisioner = LLDAPProvisioner(
|
||||||
|
base_url=os.environ["LLDAP_URL"],
|
||||||
|
admin_password=os.environ["LLDAP_ADMIN_PASSWORD"],
|
||||||
|
)
|
||||||
|
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"]
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
32
identity-provisioner/tests/test_provisioner.py
Normal file
32
identity-provisioner/tests/test_provisioner.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parents[1]))
|
||||||
|
from provisioner import _username, dispatch, Result
|
||||||
|
|
||||||
|
|
||||||
|
class Fake:
|
||||||
|
def provision(self, payload): return Result("p", "u", "password_setup_required", False)
|
||||||
|
def suspend(self, subject): return Result("p", subject, "suspended", False)
|
||||||
|
def reactivate(self, subject): return Result("p", subject, "active", False)
|
||||||
|
def deprovision(self, subject): return Result("p", subject, "deprovisioned", False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionerTests(unittest.TestCase):
|
||||||
|
def test_username_is_stable_and_sanitized(self):
|
||||||
|
self.assertEqual("bernd.worsch", _username("Bernd.Worsch@binky-hedgehog.com"))
|
||||||
|
|
||||||
|
def test_dispatch_requires_idempotency(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "idempotency_key"):
|
||||||
|
dispatch(Fake(), "/v1/identities/suspend", {
|
||||||
|
"external_subject": "u", "correlation_id": "c"
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_lifecycle_dispatch(self):
|
||||||
|
result = dispatch(Fake(), "/v1/identities/suspend", {
|
||||||
|
"external_subject": "u",
|
||||||
|
"idempotency_key": "1234567890123456",
|
||||||
|
"correlation_id": "c",
|
||||||
|
})
|
||||||
|
self.assertEqual("suspended", result.status)
|
||||||
99
sso-mfa/k8s/identity-provisioner/deployment.yaml
Normal file
99
sso-mfa/k8s/identity-provisioner/deployment.yaml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: identity-provisioner
|
||||||
|
namespace: sso
|
||||||
|
labels: &labels
|
||||||
|
app.kubernetes.io/name: identity-provisioner
|
||||||
|
app.kubernetes.io/component: directory-lifecycle
|
||||||
|
app.kubernetes.io/part-of: net-kingdom-sso-mfa
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: identity-provisioner}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: *labels
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
runAsGroup: 10001
|
||||||
|
seccompProfile: {type: RuntimeDefault}
|
||||||
|
containers:
|
||||||
|
- name: provisioner
|
||||||
|
image: identity-provisioner:20260728-1
|
||||||
|
imagePullPolicy: Never
|
||||||
|
ports: [{name: http, containerPort: 8080}]
|
||||||
|
env:
|
||||||
|
- {name: LLDAP_URL, value: "http://lldap.sso.svc.cluster.local:17170"}
|
||||||
|
- name: LLDAP_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: {name: lldap-secrets, key: LLDAP_LDAP_USER_PASS}
|
||||||
|
- name: PROVISIONER_SERVICE_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: {name: identity-provisioner-token, key: token}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: {drop: ["ALL"]}
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
|
limits: {cpu: 250m, memory: 128Mi}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
periodSeconds: 20
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: identity-provisioner
|
||||||
|
namespace: sso
|
||||||
|
spec:
|
||||||
|
selector: {app.kubernetes.io/name: identity-provisioner}
|
||||||
|
ports: [{name: http, port: 8080, targetPort: http}]
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: identity-provisioner
|
||||||
|
namespace: sso
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: identity-provisioner}
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels: {kubernetes.io/metadata.name: user-engine}
|
||||||
|
podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: user-engine}
|
||||||
|
ports: [{protocol: TCP, port: 8080}]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: lldap}
|
||||||
|
ports: [{protocol: TCP, port: 17170}]
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
||||||
|
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-identity-provisioner-to-lldap
|
||||||
|
namespace: sso
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: lldap}
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: identity-provisioner}
|
||||||
|
ports: [{protocol: TCP, port: 17170}]
|
||||||
|
|
@ -62,6 +62,10 @@ spec:
|
||||||
- {name: USER_ENGINE_OIDC_REDIRECT_URI, value: "https://users.92-205-62-239.nip.io/oidc/callback"}
|
- {name: USER_ENGINE_OIDC_REDIRECT_URI, value: "https://users.92-205-62-239.nip.io/oidc/callback"}
|
||||||
- {name: USER_ENGINE_OIDC_BACKEND_URL, value: "http://keycape.sso.svc.cluster.local:8080"}
|
- {name: USER_ENGINE_OIDC_BACKEND_URL, value: "http://keycape.sso.svc.cluster.local:8080"}
|
||||||
- {name: USER_ENGINE_PUBLIC_REGISTRATION, value: "false"}
|
- {name: USER_ENGINE_PUBLIC_REGISTRATION, value: "false"}
|
||||||
|
- {name: USER_ENGINE_PROVISIONING_URL, value: "http://identity-provisioner.sso.svc.cluster.local:8080"}
|
||||||
|
- name: USER_ENGINE_PROVISIONING_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: {name: identity-provisioner-client, key: token}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities: {drop: ["ALL"]}
|
capabilities: {drop: ["ALL"]}
|
||||||
|
|
@ -117,6 +121,12 @@ spec:
|
||||||
podSelector:
|
podSelector:
|
||||||
matchLabels: {app.kubernetes.io/name: keycape}
|
matchLabels: {app.kubernetes.io/name: keycape}
|
||||||
ports: [{protocol: TCP, port: 8080}]
|
ports: [{protocol: TCP, port: 8080}]
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels: {kubernetes.io/metadata.name: sso}
|
||||||
|
podSelector:
|
||||||
|
matchLabels: {app.kubernetes.io/name: identity-provisioner}
|
||||||
|
ports: [{protocol: TCP, port: 8080}]
|
||||||
- to:
|
- to:
|
||||||
- namespaceSelector:
|
- namespaceSelector:
|
||||||
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue