From 41c598394eba4b964fe8ca1495020028525db4b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 11:37:09 -0500 Subject: [PATCH] Add Authentik OIDC client registration assets --- apps/authentik/README.md | 18 + .../clients/aistation-oidc-client.yaml | 14 + apps/authentik/clients/audit-oidc-client.yaml | 14 + apps/authentik/clients/chat-oidc-client.yaml | 14 + .../clients/distribution-oidc-client.yaml | 14 + apps/authentik/clients/dms-oidc-client.yaml | 14 + apps/authentik/clients/dns-oidc-client.yaml | 14 + .../clients/intranet-oidc-client.yaml | 14 + apps/authentik/clients/irc-oidc-client.yaml | 14 + apps/authentik/clients/kiosk-oidc-client.yaml | 14 + .../clients/knowledge-oidc-client.yaml | 14 + .../clients/library-oidc-client.yaml | 14 + .../clients/licensing-oidc-client.yaml | 14 + .../clients/llmbridge-oidc-client.yaml | 14 + apps/authentik/clients/media-oidc-client.yaml | 14 + .../clients/menuboard-oidc-client.yaml | 14 + .../clients/messageboard-oidc-client.yaml | 14 + .../clients/mike-bundle-oidc-client.yaml | 14 + apps/authentik/clients/mndot-oidc-client.yaml | 14 + apps/authentik/clients/mysql-oidc-client.yaml | 14 + apps/authentik/clients/php-oidc-client.yaml | 14 + .../clients/pimanager-oidc-client.yaml | 14 + .../clients/presentations-oidc-client.yaml | 14 + apps/authentik/clients/print-oidc-client.yaml | 14 + .../clients/provisioning-oidc-client.yaml | 14 + .../clients/remotedesktop-oidc-client.yaml | 14 + .../authentik/clients/retail-oidc-client.yaml | 14 + .../clients/scoreboards-oidc-client.yaml | 14 + .../clients/segmentdisplay-oidc-client.yaml | 14 + .../clients/signage-oidc-client.yaml | 14 + .../clients/signalcontrol-oidc-client.yaml | 14 + .../clients/telephony-oidc-client.yaml | 14 + .../clients/ttsreader-oidc-client.yaml | 14 + .../clients/worldbuilder-oidc-client.yaml | 14 + apps/authentik/kustomization.yaml | 38 ++ scripts/authentik-bulk-client-create.py | 416 ++++++++++++++++++ .../AuthentikOidcClientRegistrationTests.cs | 192 ++++++++ 37 files changed, 1126 insertions(+) create mode 100644 apps/authentik/README.md create mode 100644 apps/authentik/clients/aistation-oidc-client.yaml create mode 100644 apps/authentik/clients/audit-oidc-client.yaml create mode 100644 apps/authentik/clients/chat-oidc-client.yaml create mode 100644 apps/authentik/clients/distribution-oidc-client.yaml create mode 100644 apps/authentik/clients/dms-oidc-client.yaml create mode 100644 apps/authentik/clients/dns-oidc-client.yaml create mode 100644 apps/authentik/clients/intranet-oidc-client.yaml create mode 100644 apps/authentik/clients/irc-oidc-client.yaml create mode 100644 apps/authentik/clients/kiosk-oidc-client.yaml create mode 100644 apps/authentik/clients/knowledge-oidc-client.yaml create mode 100644 apps/authentik/clients/library-oidc-client.yaml create mode 100644 apps/authentik/clients/licensing-oidc-client.yaml create mode 100644 apps/authentik/clients/llmbridge-oidc-client.yaml create mode 100644 apps/authentik/clients/media-oidc-client.yaml create mode 100644 apps/authentik/clients/menuboard-oidc-client.yaml create mode 100644 apps/authentik/clients/messageboard-oidc-client.yaml create mode 100644 apps/authentik/clients/mike-bundle-oidc-client.yaml create mode 100644 apps/authentik/clients/mndot-oidc-client.yaml create mode 100644 apps/authentik/clients/mysql-oidc-client.yaml create mode 100644 apps/authentik/clients/php-oidc-client.yaml create mode 100644 apps/authentik/clients/pimanager-oidc-client.yaml create mode 100644 apps/authentik/clients/presentations-oidc-client.yaml create mode 100644 apps/authentik/clients/print-oidc-client.yaml create mode 100644 apps/authentik/clients/provisioning-oidc-client.yaml create mode 100644 apps/authentik/clients/remotedesktop-oidc-client.yaml create mode 100644 apps/authentik/clients/retail-oidc-client.yaml create mode 100644 apps/authentik/clients/scoreboards-oidc-client.yaml create mode 100644 apps/authentik/clients/segmentdisplay-oidc-client.yaml create mode 100644 apps/authentik/clients/signage-oidc-client.yaml create mode 100644 apps/authentik/clients/signalcontrol-oidc-client.yaml create mode 100644 apps/authentik/clients/telephony-oidc-client.yaml create mode 100644 apps/authentik/clients/ttsreader-oidc-client.yaml create mode 100644 apps/authentik/clients/worldbuilder-oidc-client.yaml create mode 100644 apps/authentik/kustomization.yaml create mode 100644 scripts/authentik-bulk-client-create.py create mode 100644 tests/bluejay-infra-lint/AuthentikOidcClientRegistrationTests.cs diff --git a/apps/authentik/README.md b/apps/authentik/README.md new file mode 100644 index 0000000..f8c6033 --- /dev/null +++ b/apps/authentik/README.md @@ -0,0 +1,18 @@ +# Authentik OIDC client registration sweep + +This directory holds the FlowerCore per-service OIDC client secret references +for the ADR-093 / ADR-124 Phase 1 step 8 sweep. + +The `clients/*-oidc-client.yaml` manifests are intentionally only +`OnePasswordItem` CRDs. The actual 1Password items are created by an operator in +the `IAmWorkin` vault with these fields: + +| Field | Purpose | +| --- | --- | +| `client_id` | Authentik provider client id, default `` | +| `client_secret` | Authentik provider client secret | +| `issuer_url` | `https://id.iamworkin.lan/application/o//` | + +Run `scripts/authentik-bulk-client-create.py` in dry-run mode first. Live REST +mutation requires `--apply`, `AUTHENTIK_TOKEN`, and an operator-provided +client-secret JSON file. The script redacts secrets in all normal output. diff --git a/apps/authentik/clients/aistation-oidc-client.yaml b/apps/authentik/clients/aistation-oidc-client.yaml new file mode 100644 index 0000000..d80aed4 --- /dev/null +++ b/apps/authentik/clients/aistation-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: aistation-oidc-client + namespace: fc-aistation + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: aistation + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/aistation-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/aistation-oidc-client" diff --git a/apps/authentik/clients/audit-oidc-client.yaml b/apps/authentik/clients/audit-oidc-client.yaml new file mode 100644 index 0000000..0ae4266 --- /dev/null +++ b/apps/authentik/clients/audit-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: audit-oidc-client + namespace: fc-audit + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: audit + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/audit-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/audit-oidc-client" diff --git a/apps/authentik/clients/chat-oidc-client.yaml b/apps/authentik/clients/chat-oidc-client.yaml new file mode 100644 index 0000000..a4f619b --- /dev/null +++ b/apps/authentik/clients/chat-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: chat-oidc-client + namespace: fc-chat + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: chat + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/chat-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/chat-oidc-client" diff --git a/apps/authentik/clients/distribution-oidc-client.yaml b/apps/authentik/clients/distribution-oidc-client.yaml new file mode 100644 index 0000000..dc727df --- /dev/null +++ b/apps/authentik/clients/distribution-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: distribution-oidc-client + namespace: fc-distribution + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: distribution + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/distribution-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/distribution-oidc-client" diff --git a/apps/authentik/clients/dms-oidc-client.yaml b/apps/authentik/clients/dms-oidc-client.yaml new file mode 100644 index 0000000..4d1adf4 --- /dev/null +++ b/apps/authentik/clients/dms-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: dms-oidc-client + namespace: fc-dms + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: dms + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/dms-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/dms-oidc-client" diff --git a/apps/authentik/clients/dns-oidc-client.yaml b/apps/authentik/clients/dns-oidc-client.yaml new file mode 100644 index 0000000..c1d20e8 --- /dev/null +++ b/apps/authentik/clients/dns-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: dns-oidc-client + namespace: fc-dns + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: dns + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/dns-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/dns-oidc-client" diff --git a/apps/authentik/clients/intranet-oidc-client.yaml b/apps/authentik/clients/intranet-oidc-client.yaml new file mode 100644 index 0000000..baaaa86 --- /dev/null +++ b/apps/authentik/clients/intranet-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: intranet-oidc-client + namespace: intranet + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: intranet + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/intranet-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/intranet-oidc-client" diff --git a/apps/authentik/clients/irc-oidc-client.yaml b/apps/authentik/clients/irc-oidc-client.yaml new file mode 100644 index 0000000..a9ee010 --- /dev/null +++ b/apps/authentik/clients/irc-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: irc-oidc-client + namespace: irc + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: irc + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/irc-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/irc-oidc-client" diff --git a/apps/authentik/clients/kiosk-oidc-client.yaml b/apps/authentik/clients/kiosk-oidc-client.yaml new file mode 100644 index 0000000..3ed59e7 --- /dev/null +++ b/apps/authentik/clients/kiosk-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: kiosk-oidc-client + namespace: fc-system + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: kiosk + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/kiosk-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/kiosk-oidc-client" diff --git a/apps/authentik/clients/knowledge-oidc-client.yaml b/apps/authentik/clients/knowledge-oidc-client.yaml new file mode 100644 index 0000000..9ab8ea5 --- /dev/null +++ b/apps/authentik/clients/knowledge-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: knowledge-oidc-client + namespace: knowledge + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: knowledge + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/knowledge-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/knowledge-oidc-client" diff --git a/apps/authentik/clients/library-oidc-client.yaml b/apps/authentik/clients/library-oidc-client.yaml new file mode 100644 index 0000000..3fab0eb --- /dev/null +++ b/apps/authentik/clients/library-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: library-oidc-client + namespace: fc-library + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: library + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/library-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/library-oidc-client" diff --git a/apps/authentik/clients/licensing-oidc-client.yaml b/apps/authentik/clients/licensing-oidc-client.yaml new file mode 100644 index 0000000..f6aa5be --- /dev/null +++ b/apps/authentik/clients/licensing-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: licensing-oidc-client + namespace: fc-licensing + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: licensing + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/licensing-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/licensing-oidc-client" diff --git a/apps/authentik/clients/llmbridge-oidc-client.yaml b/apps/authentik/clients/llmbridge-oidc-client.yaml new file mode 100644 index 0000000..371d0c0 --- /dev/null +++ b/apps/authentik/clients/llmbridge-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: llmbridge-oidc-client + namespace: fc-llm-bridge + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: llmbridge + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/llmbridge-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/llmbridge-oidc-client" diff --git a/apps/authentik/clients/media-oidc-client.yaml b/apps/authentik/clients/media-oidc-client.yaml new file mode 100644 index 0000000..6746245 --- /dev/null +++ b/apps/authentik/clients/media-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: media-oidc-client + namespace: fc-media + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: media + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/media-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/media-oidc-client" diff --git a/apps/authentik/clients/menuboard-oidc-client.yaml b/apps/authentik/clients/menuboard-oidc-client.yaml new file mode 100644 index 0000000..e09b052 --- /dev/null +++ b/apps/authentik/clients/menuboard-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: menuboard-oidc-client + namespace: fc-menuboard + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: menuboard + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/menuboard-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/menuboard-oidc-client" diff --git a/apps/authentik/clients/messageboard-oidc-client.yaml b/apps/authentik/clients/messageboard-oidc-client.yaml new file mode 100644 index 0000000..91435a5 --- /dev/null +++ b/apps/authentik/clients/messageboard-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: messageboard-oidc-client + namespace: fc-messageboard + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: messageboard + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/messageboard-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/messageboard-oidc-client" diff --git a/apps/authentik/clients/mike-bundle-oidc-client.yaml b/apps/authentik/clients/mike-bundle-oidc-client.yaml new file mode 100644 index 0000000..99d9951 --- /dev/null +++ b/apps/authentik/clients/mike-bundle-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: mike-bundle-oidc-client + namespace: fc-mike-bundle + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: mike-bundle + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/mike-bundle-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/mike-bundle-oidc-client" diff --git a/apps/authentik/clients/mndot-oidc-client.yaml b/apps/authentik/clients/mndot-oidc-client.yaml new file mode 100644 index 0000000..a7e7b05 --- /dev/null +++ b/apps/authentik/clients/mndot-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: mndot-oidc-client + namespace: fc-mndot + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: mndot + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/mndot-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/mndot-oidc-client" diff --git a/apps/authentik/clients/mysql-oidc-client.yaml b/apps/authentik/clients/mysql-oidc-client.yaml new file mode 100644 index 0000000..703363e --- /dev/null +++ b/apps/authentik/clients/mysql-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: mysql-oidc-client + namespace: fc-mysql + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: mysql + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/mysql-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/mysql-oidc-client" diff --git a/apps/authentik/clients/php-oidc-client.yaml b/apps/authentik/clients/php-oidc-client.yaml new file mode 100644 index 0000000..2d0fba5 --- /dev/null +++ b/apps/authentik/clients/php-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: php-oidc-client + namespace: fc-php + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: php + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/php-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/php-oidc-client" diff --git a/apps/authentik/clients/pimanager-oidc-client.yaml b/apps/authentik/clients/pimanager-oidc-client.yaml new file mode 100644 index 0000000..1833311 --- /dev/null +++ b/apps/authentik/clients/pimanager-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: pimanager-oidc-client + namespace: fc-pimanager + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: pimanager + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/pimanager-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/pimanager-oidc-client" diff --git a/apps/authentik/clients/presentations-oidc-client.yaml b/apps/authentik/clients/presentations-oidc-client.yaml new file mode 100644 index 0000000..a497e60 --- /dev/null +++ b/apps/authentik/clients/presentations-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: presentations-oidc-client + namespace: fc-presentations + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: presentations + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/presentations-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/presentations-oidc-client" diff --git a/apps/authentik/clients/print-oidc-client.yaml b/apps/authentik/clients/print-oidc-client.yaml new file mode 100644 index 0000000..c0ada8f --- /dev/null +++ b/apps/authentik/clients/print-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: print-oidc-client + namespace: fc-print + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: print + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/print-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/print-oidc-client" diff --git a/apps/authentik/clients/provisioning-oidc-client.yaml b/apps/authentik/clients/provisioning-oidc-client.yaml new file mode 100644 index 0000000..acbc170 --- /dev/null +++ b/apps/authentik/clients/provisioning-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: provisioning-oidc-client + namespace: fc-provisioning + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: provisioning + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/provisioning-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/provisioning-oidc-client" diff --git a/apps/authentik/clients/remotedesktop-oidc-client.yaml b/apps/authentik/clients/remotedesktop-oidc-client.yaml new file mode 100644 index 0000000..1e5d369 --- /dev/null +++ b/apps/authentik/clients/remotedesktop-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: remotedesktop-oidc-client + namespace: fc-desktop + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: remotedesktop + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/remotedesktop-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/remotedesktop-oidc-client" diff --git a/apps/authentik/clients/retail-oidc-client.yaml b/apps/authentik/clients/retail-oidc-client.yaml new file mode 100644 index 0000000..07a9d65 --- /dev/null +++ b/apps/authentik/clients/retail-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: retail-oidc-client + namespace: fc-retail + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: retail + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/retail-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/retail-oidc-client" diff --git a/apps/authentik/clients/scoreboards-oidc-client.yaml b/apps/authentik/clients/scoreboards-oidc-client.yaml new file mode 100644 index 0000000..6e6abe2 --- /dev/null +++ b/apps/authentik/clients/scoreboards-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: scoreboards-oidc-client + namespace: fc-scoreboard + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: scoreboards + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/scoreboards-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/scoreboards-oidc-client" diff --git a/apps/authentik/clients/segmentdisplay-oidc-client.yaml b/apps/authentik/clients/segmentdisplay-oidc-client.yaml new file mode 100644 index 0000000..17463f6 --- /dev/null +++ b/apps/authentik/clients/segmentdisplay-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: segmentdisplay-oidc-client + namespace: fc-segmentdisplay + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: segmentdisplay + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/segmentdisplay-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/segmentdisplay-oidc-client" diff --git a/apps/authentik/clients/signage-oidc-client.yaml b/apps/authentik/clients/signage-oidc-client.yaml new file mode 100644 index 0000000..4b54719 --- /dev/null +++ b/apps/authentik/clients/signage-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: signage-oidc-client + namespace: fc-signage + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: signage + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/signage-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/signage-oidc-client" diff --git a/apps/authentik/clients/signalcontrol-oidc-client.yaml b/apps/authentik/clients/signalcontrol-oidc-client.yaml new file mode 100644 index 0000000..a2fd74c --- /dev/null +++ b/apps/authentik/clients/signalcontrol-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: signalcontrol-oidc-client + namespace: fc-signalcontrol + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: signalcontrol + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/signalcontrol-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/signalcontrol-oidc-client" diff --git a/apps/authentik/clients/telephony-oidc-client.yaml b/apps/authentik/clients/telephony-oidc-client.yaml new file mode 100644 index 0000000..aa703c5 --- /dev/null +++ b/apps/authentik/clients/telephony-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: telephony-oidc-client + namespace: telephony + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: telephony + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/telephony-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/telephony-oidc-client" diff --git a/apps/authentik/clients/ttsreader-oidc-client.yaml b/apps/authentik/clients/ttsreader-oidc-client.yaml new file mode 100644 index 0000000..1f9617c --- /dev/null +++ b/apps/authentik/clients/ttsreader-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: ttsreader-oidc-client + namespace: fc-ttsreader + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: ttsreader + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/ttsreader-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/ttsreader-oidc-client" diff --git a/apps/authentik/clients/worldbuilder-oidc-client.yaml b/apps/authentik/clients/worldbuilder-oidc-client.yaml new file mode 100644 index 0000000..e1aed33 --- /dev/null +++ b/apps/authentik/clients/worldbuilder-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: worldbuilder-oidc-client + namespace: fc-worldbuilder + labels: + app.kubernetes.io/part-of: flowercore + app.kubernetes.io/component: authentik-oidc-client + flowercore.io/authentik-client-slug: worldbuilder + annotations: + flowercore.io/onepassword-item: "IAmWorkin/items/worldbuilder-oidc-client" + flowercore.io/expected-fields: "client_id,client_secret,issuer_url" +spec: + itemPath: "vaults/IAmWorkin/items/worldbuilder-oidc-client" diff --git a/apps/authentik/kustomization.yaml b/apps/authentik/kustomization.yaml new file mode 100644 index 0000000..5b16011 --- /dev/null +++ b/apps/authentik/kustomization.yaml @@ -0,0 +1,38 @@ +# ArgoCD's bluejay-infra ApplicationSet sees apps/authentik as one app. Keep +# an explicit resource list so the client manifests can live under clients/. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - clients/library-oidc-client.yaml + - clients/retail-oidc-client.yaml + - clients/telephony-oidc-client.yaml + - clients/knowledge-oidc-client.yaml + - clients/llmbridge-oidc-client.yaml + - clients/mysql-oidc-client.yaml + - clients/php-oidc-client.yaml + - clients/signage-oidc-client.yaml + - clients/media-oidc-client.yaml + - clients/dms-oidc-client.yaml + - clients/pimanager-oidc-client.yaml + - clients/distribution-oidc-client.yaml + - clients/dns-oidc-client.yaml + - clients/print-oidc-client.yaml + - clients/aistation-oidc-client.yaml + - clients/irc-oidc-client.yaml + - clients/ttsreader-oidc-client.yaml + - clients/chat-oidc-client.yaml + - clients/intranet-oidc-client.yaml + - clients/remotedesktop-oidc-client.yaml + - clients/provisioning-oidc-client.yaml + - clients/scoreboards-oidc-client.yaml + - clients/mndot-oidc-client.yaml + - clients/kiosk-oidc-client.yaml + - clients/mike-bundle-oidc-client.yaml + - clients/messageboard-oidc-client.yaml + - clients/menuboard-oidc-client.yaml + - clients/presentations-oidc-client.yaml + - clients/segmentdisplay-oidc-client.yaml + - clients/signalcontrol-oidc-client.yaml + - clients/worldbuilder-oidc-client.yaml + - clients/audit-oidc-client.yaml + - clients/licensing-oidc-client.yaml diff --git a/scripts/authentik-bulk-client-create.py b/scripts/authentik-bulk-client-create.py new file mode 100644 index 0000000..b1a23b4 --- /dev/null +++ b/scripts/authentik-bulk-client-create.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Generate and optionally apply FlowerCore Authentik OIDC client assets. + +Dry-run is the default. Live Authentik mutations require --apply plus an +AUTHENTIK_TOKEN bearer token and an operator-provided client secret JSON file. +The script never prints client_secret values. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + + +CLAIMS = ( + "fc:roles", + "fc:tenant", + "fc:svc", + "fc:scope", + "fc:mfa", + "flowercore_actor_id", +) + +BUILTIN_SCOPE_MAPPINGS = ( + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-offline_access", +) + +FLOW_CONTRACT = { + "response_type": "code", + "grant_types": ["authorization_code", "refresh_token"], + "offline_access_required": True, +} + + +@dataclass(frozen=True) +class ServiceSpec: + slug: str + namespace: str + display_name: str + host: str + + @property + def client_id(self) -> str: + return self.slug + + @property + def provider_name(self) -> str: + return f"FlowerCore {self.display_name} OIDC" + + @property + def application_name(self) -> str: + return f"FlowerCore {self.display_name}" + + @property + def issuer_url(self) -> str: + return f"https://id.iamworkin.lan/application/o/{self.slug}/" + + @property + def onepassword_item_path(self) -> str: + return f"IAmWorkin/items/{self.slug}-oidc-client" + + +SERVICE_SPECS = ( + ServiceSpec("library", "fc-library", "Library", "library.iamworkin.lan"), + ServiceSpec("retail", "fc-retail", "Retail", "retail.iamworkin.lan"), + ServiceSpec("telephony", "telephony", "Telephony", "telephony.iamworkin.lan"), + ServiceSpec("knowledge", "knowledge", "Knowledge", "knowledge.iamworkin.lan"), + ServiceSpec("llmbridge", "fc-llm-bridge", "LlmBridge", "fc-llm-bridge.iamworkin.lan"), + ServiceSpec("mysql", "fc-mysql", "MySQL", "mysql.iamworkin.lan"), + ServiceSpec("php", "fc-php", "PHP", "php.iamworkin.lan"), + ServiceSpec("signage", "fc-signage", "Signage", "signage.iamworkin.lan"), + ServiceSpec("media", "fc-media", "Media", "media.iamworkin.lan"), + ServiceSpec("dms", "fc-dms", "DMS", "dms.iamworkin.lan"), + ServiceSpec("pimanager", "fc-pimanager", "PiManager", "pimanager.iamworkin.lan"), + ServiceSpec("distribution", "fc-distribution", "Distribution", "distribution.iamworkin.lan"), + ServiceSpec("dns", "fc-dns", "DNS", "dns.iamworkin.lan"), + ServiceSpec("print", "fc-print", "Print", "print.iamworkin.lan"), + ServiceSpec("aistation", "fc-aistation", "AiStation", "aistation.iamworkin.lan"), + ServiceSpec("irc", "irc", "IRC", "irc.iamworkin.lan"), + ServiceSpec("ttsreader", "fc-ttsreader", "TtsReader", "ttsreader.iamworkin.lan"), + ServiceSpec("chat", "fc-chat", "Chat", "chat.iamworkin.lan"), + ServiceSpec("intranet", "intranet", "Intranet", "intranet.iamworkin.lan"), + ServiceSpec("remotedesktop", "fc-desktop", "RemoteDesktop", "remotedesktop.iamworkin.lan"), + ServiceSpec("provisioning", "fc-provisioning", "Provisioning", "provisioning.iamworkin.lan"), + ServiceSpec("scoreboards", "fc-scoreboard", "Scoreboards", "scoreboards.iamworkin.lan"), + ServiceSpec("mndot", "fc-mndot", "MnDOT", "mndot.iamworkin.lan"), + ServiceSpec("kiosk", "fc-system", "Kiosk", "kiosk.iamworkin.lan"), + ServiceSpec("mike-bundle", "fc-mike-bundle", "Mike Bundle", "mike-bundle.iamworkin.lan"), + ServiceSpec("messageboard", "fc-messageboard", "MessageBoard", "messageboard.iamworkin.lan"), + ServiceSpec("menuboard", "fc-menuboard", "MenuBoard", "menuboard.iamworkin.lan"), + ServiceSpec("presentations", "fc-presentations", "Presentations", "presentations.iamworkin.lan"), + ServiceSpec("segmentdisplay", "fc-segmentdisplay", "SegmentDisplay", "segmentdisplay.iamworkin.lan"), + ServiceSpec("signalcontrol", "fc-signalcontrol", "SignalControl", "signalcontrol.iamworkin.lan"), + ServiceSpec("worldbuilder", "fc-worldbuilder", "WorldBuilder", "worldbuilder.iamworkin.lan"), + ServiceSpec("audit", "fc-audit", "Audit", "audit.iamworkin.lan"), + ServiceSpec("licensing", "fc-licensing", "Licensing", "licensing.iamworkin.lan"), +) + + +def scope_mapping_payloads(service: ServiceSpec) -> list[dict[str, str]]: + managed_prefix = f"flowercore.io/authentik/oidc/{service.slug}" + return [ + { + "managed": f"{managed_prefix}/fc-roles", + "name": f"FlowerCore {service.slug} fc:roles", + "scope_name": "flowercore", + "description": "FlowerCore role claim from Authentik group memberships.", + "expression": ( + "groups = [group.name for group in request.user.ak_groups.all()]\n" + "return {'fc:roles': ','.join(groups)}" + ), + }, + { + "managed": f"{managed_prefix}/fc-tenant", + "name": f"FlowerCore {service.slug} fc:tenant", + "scope_name": "flowercore", + "description": "FlowerCore tenant claim from group attribute fc_tenant_id.", + "expression": ( + "for group in request.user.ak_groups.all():\n" + " tenant_id = group.attributes.get('fc_tenant_id')\n" + " if tenant_id:\n" + " return {'fc:tenant': tenant_id}\n" + "return {'fc:tenant': 'default'}" + ), + }, + { + "managed": f"{managed_prefix}/fc-svc", + "name": f"FlowerCore {service.slug} fc:svc", + "scope_name": "flowercore", + "description": "FlowerCore service slug claim.", + "expression": f"return {{'fc:svc': '{service.slug}'}}", + }, + { + "managed": f"{managed_prefix}/fc-scope", + "name": f"FlowerCore {service.slug} fc:scope", + "scope_name": "flowercore", + "description": "FlowerCore service permission scope claim.", + "expression": f"return {{'fc:scope': 'flowercore:{service.slug}'}}", + }, + { + "managed": f"{managed_prefix}/fc-mfa", + "name": f"FlowerCore {service.slug} fc:mfa", + "scope_name": "flowercore", + "description": "FlowerCore MFA satisfied session claim.", + "expression": ( + "mfa_stage = request.session.get('authentik/stages/authenticator_validate')\n" + "return {'fc:mfa': bool(mfa_stage)}" + ), + }, + { + "managed": f"{managed_prefix}/flowercore-actor-id", + "name": f"FlowerCore {service.slug} flowercore_actor_id", + "scope_name": "flowercore", + "description": "FlowerCore audit actor alias for the Authentik user id.", + "expression": "return {'flowercore_actor_id': str(request.user.uid)}", + }, + ] + + +def provider_payload( + service: ServiceSpec, + args: argparse.Namespace, + mapping_ids: list[str], + client_secret: str, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "name": service.provider_name, + "authorization_flow": args.authorization_flow, + "invalidation_flow": args.invalidation_flow, + "property_mappings": mapping_ids, + "client_type": "confidential", + "client_id": service.client_id, + "client_secret": client_secret, + "access_code_validity": "minutes=1", + "access_token_validity": "hours=1", + "refresh_token_validity": "days=30", + "include_claims_in_id_token": True, + "redirect_uris": [ + {"matching_mode": "strict", "url": f"https://{service.host}/signin-oidc"}, + {"matching_mode": "strict", "url": f"https://{service.host}/signout-callback-oidc"}, + ], + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + } + + if args.authentication_flow: + payload["authentication_flow"] = args.authentication_flow + if args.signing_key: + payload["signing_key"] = args.signing_key + + return payload + + +def application_payload(service: ServiceSpec, provider_pk: int | str | None) -> dict[str, Any]: + return { + "name": service.application_name, + "slug": service.slug, + "provider": provider_pk, + "open_in_new_tab": True, + "meta_launch_url": f"https://{service.host}/", + "meta_description": f"FlowerCore {service.display_name} OIDC client", + "meta_publisher": "FlowerCore", + "policy_engine_mode": "all", + "group": "FlowerCore", + } + + +def load_client_secrets(path: str | None) -> dict[str, str]: + if not path: + return {} + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + raise ValueError("client secret file must be a JSON object keyed by service slug") + return {str(key): str(value) for key, value in data.items()} + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: "" if key == "client_secret" else redact(child) + for key, child in value.items() + } + if isinstance(value, list): + return [redact(child) for child in value] + return value + + +class AuthentikClient: + def __init__(self, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + + def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: + url = f"{self.base_url}/api/v3/{path.lstrip('/')}" + body = None + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.token}", + } + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode("utf-8") + + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=30) as response: + text = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + error_text = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {error_text}") from error + + if not text: + return None + return json.loads(text) + + def first_result(self, path: str, **query: str) -> dict[str, Any] | None: + query_string = urllib.parse.urlencode(query) + response = self.request("GET", f"{path}?{query_string}") + results = response.get("results", []) if isinstance(response, dict) else [] + return results[0] if results else None + + +def select_services(slugs: list[str]) -> list[ServiceSpec]: + if not slugs: + return list(SERVICE_SPECS) + + by_slug = {service.slug: service for service in SERVICE_SPECS} + unknown = sorted(set(slugs) - set(by_slug)) + if unknown: + raise ValueError(f"unknown service slug(s): {', '.join(unknown)}") + return [by_slug[slug] for slug in slugs] + + +def validate_specs(services: list[ServiceSpec]) -> None: + slugs = [service.slug for service in services] + if len(slugs) != len(set(slugs)): + raise ValueError("duplicate service slug in OIDC roster") + for service in services: + if not service.namespace: + raise ValueError(f"{service.slug} is missing a target namespace") + expressions = "\n".join(mapping["expression"] for mapping in scope_mapping_payloads(service)) + missing_claims = [claim for claim in CLAIMS if claim not in expressions] + if missing_claims: + raise ValueError(f"{service.slug} mapping payloads miss claims: {', '.join(missing_claims)}") + + +def dry_run(services: list[ServiceSpec], args: argparse.Namespace) -> int: + placeholder_ids = [ + *[f"" for managed in BUILTIN_SCOPE_MAPPINGS], + *[f"<{claim}-mapping-pk>" for claim in CLAIMS], + ] + documents = [] + for service in services: + provider = provider_payload(service, args, placeholder_ids, "") + documents.append( + { + "service": service.slug, + "namespace": service.namespace, + "onepassword_item": service.onepassword_item_path, + "issuer_url": service.issuer_url, + "flow_contract": FLOW_CONTRACT, + "builtin_scope_mappings": list(BUILTIN_SCOPE_MAPPINGS), + "scope_mappings": scope_mapping_payloads(service), + "provider": redact(provider), + "application": application_payload(service, ""), + } + ) + + if args.print_json: + print(json.dumps(documents, indent=2, sort_keys=True)) + else: + print( + "Dry-run only: generated " + f"{len(services)} providers, {len(services)} applications, " + f"and {len(services) * len(CLAIMS)} scope mappings." + ) + print("Use --print-json to inspect redacted payloads; use --apply for live Authentik mutation.") + return 0 + + +def apply(services: list[ServiceSpec], args: argparse.Namespace) -> int: + token = os.environ.get("AUTHENTIK_TOKEN") + if not token: + raise ValueError("AUTHENTIK_TOKEN is required with --apply") + if not args.client_secrets_json: + raise ValueError("--client-secrets-json is required with --apply") + + secrets = load_client_secrets(args.client_secrets_json) + missing = [service.slug for service in services if not secrets.get(service.slug)] + if missing: + raise ValueError(f"client secret JSON is missing slug(s): {', '.join(missing)}") + + client = AuthentikClient(args.base_url, token) + for service in services: + mapping_ids: list[str] = [] + for managed in BUILTIN_SCOPE_MAPPINGS: + existing = client.first_result("/propertymappings/provider/scope/", managed=managed) + if not existing: + raise ValueError(f"built-in Authentik scope mapping not found: {managed}") + mapping_ids.append(existing["pk"]) + + for mapping in scope_mapping_payloads(service): + existing = client.first_result("/propertymappings/provider/scope/", name=mapping["name"]) + if existing and not args.update_existing: + mapping_ids.append(existing["pk"]) + continue + if existing and args.update_existing: + updated = client.request("PATCH", f"/propertymappings/provider/scope/{existing['pk']}/", mapping) + mapping_ids.append(updated["pk"]) + continue + created = client.request("POST", "/propertymappings/provider/scope/", mapping) + mapping_ids.append(created["pk"]) + + existing_provider = client.first_result("/providers/oauth2/", client_id=service.client_id) + provider_body = provider_payload(service, args, mapping_ids, secrets[service.slug]) + if existing_provider and not args.update_existing: + provider_pk = existing_provider["pk"] + elif existing_provider: + updated_provider = client.request("PATCH", f"/providers/oauth2/{existing_provider['pk']}/", provider_body) + provider_pk = updated_provider["pk"] + else: + provider_pk = client.request("POST", "/providers/oauth2/", provider_body)["pk"] + + app_body = application_payload(service, provider_pk) + existing_app = client.first_result("/core/applications/", slug=service.slug) + if existing_app and args.update_existing: + client.request("PATCH", f"/core/applications/{existing_app['slug']}/", app_body) + elif not existing_app: + client.request("POST", "/core/applications/", app_body) + + print(f"applied {service.slug}: provider/app present, secret redacted") + + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="perform live Authentik REST mutations") + parser.add_argument("--update-existing", action="store_true", help="patch existing mappings/providers/applications") + parser.add_argument("--print-json", action="store_true", help="print redacted dry-run payloads") + parser.add_argument("--service", action="append", default=[], help="limit to one service slug; repeatable") + parser.add_argument("--base-url", default="http://localhost:9000", help="Authentik base URL") + parser.add_argument("--client-secrets-json", help="operator-provided JSON object of slug to client_secret") + parser.add_argument("--authorization-flow", default="") + parser.add_argument("--invalidation-flow", default="") + parser.add_argument("--authentication-flow") + parser.add_argument("--signing-key", help="shared Authentik signing key UUID") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + services = select_services(args.service) + validate_specs(services) + if args.apply: + return apply(services, args) + return dry_run(services, args) + except Exception as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/bluejay-infra-lint/AuthentikOidcClientRegistrationTests.cs b/tests/bluejay-infra-lint/AuthentikOidcClientRegistrationTests.cs new file mode 100644 index 0000000..25212a1 --- /dev/null +++ b/tests/bluejay-infra-lint/AuthentikOidcClientRegistrationTests.cs @@ -0,0 +1,192 @@ +using FluentAssertions; +using System.Diagnostics; +using System.Text.RegularExpressions; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "AuthentikOidc")] +public sealed class AuthentikOidcClientRegistrationTests +{ + private static readonly IReadOnlyList ExpectedClients = + [ + new("library", "fc-library"), + new("retail", "fc-retail"), + new("telephony", "telephony"), + new("knowledge", "knowledge"), + new("llmbridge", "fc-llm-bridge"), + new("mysql", "fc-mysql"), + new("php", "fc-php"), + new("signage", "fc-signage"), + new("media", "fc-media"), + new("dms", "fc-dms"), + new("pimanager", "fc-pimanager"), + new("distribution", "fc-distribution"), + new("dns", "fc-dns"), + new("print", "fc-print"), + new("aistation", "fc-aistation"), + new("irc", "irc"), + new("ttsreader", "fc-ttsreader"), + new("chat", "fc-chat"), + new("intranet", "intranet"), + new("remotedesktop", "fc-desktop"), + new("provisioning", "fc-provisioning"), + new("scoreboards", "fc-scoreboard"), + new("mndot", "fc-mndot"), + new("kiosk", "fc-system"), + new("mike-bundle", "fc-mike-bundle"), + new("messageboard", "fc-messageboard"), + new("menuboard", "fc-menuboard"), + new("presentations", "fc-presentations"), + new("segmentdisplay", "fc-segmentdisplay"), + new("signalcontrol", "fc-signalcontrol"), + new("worldbuilder", "fc-worldbuilder"), + new("audit", "fc-audit"), + new("licensing", "fc-licensing"), + ]; + + public static TheoryData ExpectedClientRows() + { + var data = new TheoryData(); + foreach (var client in ExpectedClients) + { + data.Add(client.Slug, client.Namespace); + } + + return data; + } + + [Theory] + [MemberData(nameof(ExpectedClientRows))] + public void OidcClientManifest_MatchesOnePasswordOperatorContract(string slug, string targetNamespace) + { + var manifest = LoadClientManifest(slug); + manifest.Scalar("apiVersion").Should().Be("onepassword.com/v1"); + manifest.Scalar("kind").Should().Be("OnePasswordItem"); + manifest.Scalar("metadata", "name").Should().Be($"{slug}-oidc-client"); + manifest.Scalar("metadata", "namespace").Should().Be(targetNamespace); + manifest.Scalar("metadata", "labels", "app.kubernetes.io/component") + .Should().Be("authentik-oidc-client"); + manifest.Scalar("metadata", "labels", "flowercore.io/authentik-client-slug") + .Should().Be(slug); + manifest.Scalar("metadata", "annotations", "flowercore.io/expected-fields") + .Should().Be("client_id,client_secret,issuer_url"); + manifest.Scalar("spec", "itemPath") + .Should().Be($"vaults/IAmWorkin/items/{slug}-oidc-client"); + } + + [Fact] + public void AuthentikKustomization_ReferencesEveryClientManifest() + { + var kustomizationPath = Path.Combine(BluejayRoot(), "apps", "authentik", "kustomization.yaml"); + var text = File.ReadAllText(kustomizationPath); + + foreach (var client in ExpectedClients) + { + text.Should().Contain($"clients/{client.Slug}-oidc-client.yaml"); + } + + Regex.Matches(text, @"clients/[-a-z0-9]+-oidc-client\.yaml") + .Select(match => match.Value) + .Distinct(StringComparer.Ordinal) + .Should() + .HaveCount(ExpectedClients.Count); + } + + [Fact] + public void BulkClientScript_HasDryRunDefaultAndRequiredClaimPayloads() + { + var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py"); + var script = File.ReadAllText(scriptPath); + + script.Should().Contain("--apply"); + script.Should().Contain("Dry-run only"); + script.Should().Contain("AUTHENTIK_TOKEN"); + script.Should().Contain("client_secrets_json"); + script.Should().Contain("scope-offline_access"); + script.Should().Contain("authorization_code"); + script.Should().Contain("refresh_token"); + + foreach (var claim in new[] { "fc:roles", "fc:tenant", "fc:svc", "fc:scope", "fc:mfa", "flowercore_actor_id" }) + { + script.Should().Contain(claim); + } + } + + [Fact] + public void BulkClientScript_DryRunGeneratesAllServicesWithoutSecrets() + { + var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py"); + var startInfo = new ProcessStartInfo + { + FileName = "python", + WorkingDirectory = BluejayRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add("--print-json"); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start python dry-run."); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(15000).Should().BeTrue(stderr); + process.ExitCode.Should().Be(0, stderr); + + foreach (var client in ExpectedClients) + { + stdout.Should().Contain($"\"service\": \"{client.Slug}\""); + stdout.Should().Contain($"\"namespace\": \"{client.Namespace}\""); + } + + stdout.Should().Contain("\"client_secret\": \"\""); + stdout.Should().NotMatchRegex("\"client_secret\"\\s*:\\s*\"(?!)[^\"]+\""); + } + + [Fact] + public void ClientManifests_DoNotContainInlineSecretMaterial() + { + foreach (var client in ExpectedClients) + { + var path = ClientManifestPath(client.Slug); + var text = File.ReadAllText(path); + + text.Should().NotContain("client_secret:"); + text.Should().NotContain("password:"); + text.Should().NotContain("secret:"); + text.Should().Contain($"IAmWorkin/items/{client.Slug}-oidc-client"); + } + } + + private static YamlMappingNode LoadClientManifest(string slug) + { + using var reader = File.OpenText(ClientManifestPath(slug)); + var stream = new YamlStream(); + stream.Load(reader); + return stream.Documents[0].RootNode.Should().BeOfType().Subject; + } + + private static string ClientManifestPath(string slug) => + Path.Combine(BluejayRoot(), "apps", "authentik", "clients", $"{slug}-oidc-client.yaml"); + + private static string BluejayRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, "apps")) + && File.Exists(Path.Combine(current.FullName, "README.md"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not find bluejay-infra root."); + } + + private sealed record ServiceClientExpectation(string Slug, string Namespace); +}