Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
eaab062
Add OpenID IngressRoute
KavanPrice Mar 13, 2025
fb59dd8
Add Keycloak deployment
KavanPrice Mar 13, 2025
45aac27
Add startup realm importing
KavanPrice Mar 17, 2025
cb867ca
Add oauth to Grafana config
KavanPrice Mar 17, 2025
614a94e
Add keytab volume
KavanPrice Mar 17, 2025
8d0668b
Add requested PR changes
djnewbould Mar 21, 2025
0d0ea83
Add local secret grafana mount
djnewbould Mar 21, 2025
127c692
Add realm creation to service-setup
KavanPrice Mar 25, 2025
f73f3b6
Add service lookup for provided client configs
KavanPrice Mar 25, 2025
da8c668
Add Grafana client config values
KavanPrice Mar 25, 2025
dd65a24
Fix keytab secret names
KavanPrice Mar 25, 2025
4205c43
Add client_secret volume mount
KavanPrice Mar 25, 2025
101fe91
Fix config and add startup backoff
KavanPrice Mar 25, 2025
7200867
Add generic client-secret fetching
KavanPrice Mar 25, 2025
acdc62a
Rename client-secret to be findable by service-setup
KavanPrice Mar 25, 2025
ac7c368
Fix misnamed grafana client ID
KavanPrice Mar 26, 2025
4f6daea
Fix misnamed grafana secret
KavanPrice Mar 26, 2025
b35cf42
Change to use realm instead of base URL in ACS config
KavanPrice Mar 26, 2025
9a80e55
Use setTimeout from timers/promises
KavanPrice Mar 26, 2025
284b8d9
Timeout bugfix
KavanPrice Mar 26, 2025
15ed6a7
Remove unused client keytab
KavanPrice Mar 26, 2025
e935fbb
Add dynamic LocalSecret generation
KavanPrice Mar 26, 2025
e0b480f
Fix helm loop bug
KavanPrice Mar 26, 2025
3493233
Remove `items` from keytabs mount
amrc-benmorrow Mar 27, 2025
e51b307
Allow OpenID errors to cause service-setup to exit
amrc-benmorrow Mar 27, 2025
47fab3e
Reference client secrets correctly
amrc-benmorrow Mar 27, 2025
b4e4963
Merge in testing/v4
amrc-benmorrow Mar 27, 2025
98af79f
Remove Grafana Basic auth middleware
amrc-benmorrow Mar 27, 2025
94e7ed0
Log realm details on creation
amrc-benmorrow Mar 27, 2025
e83d828
Give Keycloack a PVC
amrc-benmorrow Mar 27, 2025
fcb2dc1
Log full client information
amrc-benmorrow Mar 27, 2025
d075eee
Tell Grafana its external root URL
amrc-benmorrow Mar 27, 2025
4f97679
Disable Grafana auth via the middleware
amrc-benmorrow Mar 27, 2025
d9a6c76
Grafana config changes
amrc-benmorrow Mar 27, 2025
688292b
Java doesn't accept KRB5_CONFIG
amrc-benmorrow Mar 27, 2025
fea8727
Keycloak needs to use Recreate strategy
amrc-benmorrow Mar 27, 2025
8c20987
Fix login and logout for basic users
KavanPrice Mar 30, 2025
08a3193
Add client role mapping
KavanPrice Mar 31, 2025
f17aa0e
Add client role creation
KavanPrice Mar 31, 2025
edb12fc
Add admin-cli permissions to create users
KavanPrice Apr 1, 2025
7fa281d
Add default role to client
KavanPrice Apr 1, 2025
5189e03
Add creation of admin user
KavanPrice Apr 1, 2025
c4a33d2
Add client role mapping for admin user
KavanPrice Apr 1, 2025
9d44e3d
Refactor OpenID setup
amrc-benmorrow Apr 3, 2025
cf237dc
Always create client roles
amrc-benmorrow Apr 3, 2025
b7e7294
Use a LocalSecret for the admin service client
amrc-benmorrow Apr 3, 2025
28b296d
Fix some logging
amrc-benmorrow Apr 3, 2025
c177e65
Missing await
amrc-benmorrow Apr 3, 2025
ae0290b
We need to call try_fetch, not native fetch
amrc-benmorrow Apr 3, 2025
c77c634
Error in admin user creation
amrc-benmorrow Apr 3, 2025
62c36cd
The admin@ account is no longer the service account
amrc-benmorrow Apr 3, 2025
01a0585
Log if the admin user doesn't exist
amrc-benmorrow Apr 3, 2025
628e344
UserRepresentation.credentials is an array
amrc-benmorrow Apr 3, 2025
da8e5d9
We must clone a Request to reuse it
amrc-benmorrow Apr 3, 2025
fbfbdfc
Rename the Keycloak bootstrap user
amrc-benmorrow Apr 3, 2025
1461db3
Create admin@ linked to Kerberos
amrc-benmorrow Apr 4, 2025
2034cae
Assign admin user roles correctly
amrc-benmorrow Apr 4, 2025
1d6aea2
Remove Grafana admin user secret
amrc-benmorrow Apr 4, 2025
b063321
We can't remove the grafana-internal admin user
amrc-benmorrow Apr 4, 2025
e263eac
Assign Grafana roles from OAuth
amrc-benmorrow Apr 4, 2025
847a97b
Try to get OpenID roles visible in Grafana
amrc-benmorrow Apr 4, 2025
a379116
Restore OpenID role mapper
amrc-benmorrow Apr 4, 2025
b4f4da2
We need to look up the client ID.
amrc-benmorrow Apr 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions acs-service-setup/lib/openid-realm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import { URLSearchParams } from "url";

/**
* Create the startup OpenID realm `factory_plus`.
*
* @async
* @param {ServiceSetup} service_setup - Contains the configuration for setting up the realm.
* @returns {Promise<void>} Resolves when the realm creation process finishes.
*/
export async function create_openid_realm(service_setup) {
await new RealmSetup(service_setup).run();
}

class RealmSetup {
constructor(service_setup) {
const { fplus } = service_setup;
this.log = fplus.debug.bound("oauth");

this.username = fplus.opts.username;
this.password = fplus.opts.password;
this.realm = "factory_plus";
this.base_url = service_setup.acs_config.domain;
this.secure = service_setup.acs_config.secure;
this.access_token = "";
this.refresh_token = "";
this.config = service_setup.config;
}

/**
* Run setup for the realm. This generates the full realm representation.
*
* A basic realm is created first and then populated with clients.
*
* @async
* @returns {Promise<void>}
*/
async run() {
let base_realm = {
id: crypto.randomUUID(),
realm: this.realm,
displayName: "Factory+",
displayNameHtml: '<div class="kc-logo-text"><span>Factory+</span></div>',
enabled: true,
components: {
"org.keycloak.storage.UserStorageProvider": [
{
id: crypto.randomUUID(),
name: "Kerberos",
providerId: "kerberos",
subComponents: {},
config: {
serverPrincipal: [
`HTTP/openid.${this.base_url}@${this.base_url.toUpperCase()}`,
],
allowPasswordAuthentication: ["false"],
debug: ["true"],
keyTab: ["/etc/keytabs/server"],
cachePolicy: ["DEFAULT"],
updateProfileFirstLogin: ["false"],
kerberosRealm: [this.base_url.toUpperCase()],
enabled: ["true"],
},
},
],
},
};

await this.get_initial_access_token();
await this.create_basic_realm(base_realm, false);

const client_configs = Object.values(this.config.openidClients);
const enabled_clients = client_configs.filter(
(client) => client.enabled === true,
);
for (const client of enabled_clients) {
await this.create_client(client, false);
}
}

/**
* Create a new realm by POSTing to the OpenID service. Throws if the response is not ok.
*
* @async
* @param {Object} realm_representation - An object containing all the values that should be created for the realm.
* @param {Boolean} is_retry - Whether the request is a retry after a 401. If this is false and a 401 is returned, this will retry after refreshing the token.
* @returns {Promise<void>} Resolves when the realm is created.
*/
async create_basic_realm(realm_representation, is_retry) {
const realm_url = `http${this.secure}://openid.${this.base_url}/admin/realms`;

this.log(`Attempting basic realm creation at: ${realm_url}`);

try {
const response = await fetch(realm_url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.access_token}`,
},
body: JSON.stringify(realm_representation),
});

if (!response.ok) {
const status = response.status;

if (status == 401 && !is_retry) {
await this.get_initial_access_token(this.refresh_token);
await this.create_basic_realm(realm_representation, true);
} else if (status == 503) {
await this.wait(10000);
await this.create_basic_realm(realm_representation, false);
} else {
const error = await response.text();
throw new Error(`${status}: ${error}`);
}
}
} catch (error) {
this.log(`Couldn't setup realm: ${error}`);
}
}

/**
* Create a new client by POSTing to the OpenID service. Throws if the response is not ok.
*
* @async
* @param {Object} client_representation - An object containing all the values that should be created for the client.
* @param {Boolean} is_retry - Whether the request is a retry after a 401. If this is false and a 401 is returned, this will retry after refreshing the token.
* @returns {Promise<void>} Resolves when the client is created.
*/
async create_client(client_representation, is_retry) {
const secret_path = path.join(
"/etc/secret",
client_representation.redirectHost,
);
const content = await fs.readFile(secret_path, "utf8");
const client_secret = content.trim();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to configure Keycloak to read the secret from its own filesystem at runtime, rather than reading the file contents here? This would be more reliable against the secret being updated. (In principle we should rotate all our secrets regularly.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A second container on the keycloak pod that called into the API to update the secrets every hour would also be a reasonable solution.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at this. There are apparently ways of making it read a secret from a vault. Simplest solution might be, as you suggested, a container that just updates the secrets


const client = {
id: crypto.randomUUID(),
clientId: client_representation.clientId,
name: client_representation.name,
rootUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`,
adminUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`,
baseUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`,
enabled: true,
secret: client_secret,
redirectUris: [
`http${this.secure}://${client_representation.redirectHost}.${this.base_url}${client_representation.redirectPath}`,
],
};

const client_url = `http${this.secure}://openid.${this.base_url}/admin/realms/${this.realm}/clients`;

this.log(`Attempting client creation at: ${client_url}`);

try {
const response = await fetch(client_url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.access_token}`,
},
body: JSON.stringify(client),
});

if (!response.ok) {
const status = response.status;

if (status == 401 && !is_retry) {
await this.get_initial_access_token(this.refresh_token);
await this.create_client(client_representation, true);
} else if (status == 503) {
await this.wait(10000);
await this.create_client(client_representation, false);
} else {
const error = await response.text();
throw new Error(`${status}: ${error}`);
}
}
} catch (error) {
this.log(`Couldn't setup client: ${error}`);
}
}

/**
* Fetch an initial access token for the user in the specified realm. This should only be used during realm setup.
*
* Sets the `access_token` and `refresh_token` properties of `OAuthRealm` as a side effect.
*
* @async
* @param {string | undefined} [refresh_token] - Optional refresh token. If this is passed we use it with bearer auth.
* @returns {Promise<[string, string]} Resolves to an access token and a refresh token.
*/
async get_initial_access_token(refresh_token) {
const token_url = `http${this.secure}://openid.${this.base_url}/realms/master/protocol/openid-connect/token`;

this.log(`Attempting token request at: ${token_url}`);

const params = new URLSearchParams();
if (refresh_token != undefined) {
params.append("grant_type", "refresh_token");
params.append("client_id", "admin-cli");
params.append("refresh_token", refresh_token);
} else {
params.append("grant_type", "password");
params.append("client_id", "admin-cli");
params.append("username", this.username);
params.append("password", this.password);
}

try {
const response = await fetch(token_url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});

if (response.ok) {
const data = await response.json();
this.access_token = data.access_token;
this.refresh_token = data.refresh_token;

return [data.access_token, data.refresh_token];
} else if (response.status == 503) {
await this.wait(10000);
this.get_initial_access_token();
} else {
const status = response.status;
const error = await response.text();
throw new Error(`${status}: ${error}`);
}
} catch (error) {
this.log(
`Couldn't get an initial access token for realm setup: ${error}`,
);
}
}

async wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
}
4 changes: 4 additions & 0 deletions acs-service-setup/lib/service-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DumpLoader } from "./dumps.js";
import { fixups } from "./fixups.js";
import { setup_git_repos } from "./git-repos.js";
import { setup_local_uuids } from "./local-uuids.js";
import { create_openid_realm } from "./openid-realm.js";

export class ServiceSetup {
constructor (opts) {
Expand Down Expand Up @@ -58,6 +59,9 @@ export class ServiceSetup {
this.log("Migrating legacy Auth groups");
await migrate_auth_groups(this);

this.log("Creating OpenID realm");
await create_openid_realm(this);

this.log("Finished setup");
}
}
51 changes: 51 additions & 0 deletions deploy/crds/local-secret.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: localsecrets.factoryplus.app.amrc.co.uk
spec:
group: factoryplus.app.amrc.co.uk
names:
kind: LocalSecret
plural: localsecrets
categories:
- all
scope: Namespaced
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Secret
jsonPath: ".spec.secret"
type: string
- name: Key
jsonPath: ".spec.key"
type: string
- name: Format
jsonPath: ".spec.format"
type: string
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [secret, key, format]
properties:
secret:
description: The name of the Secret to edit.
type: string
key:
description: The key to create within the Secret.
type: string
format:
description: >
The format of the secret value. Currently must be Password.
type: string
enum: [Password]
status:
type: object
x-kubernetes-preserve-unknown-fields: true
subresources:
status: {}
9 changes: 9 additions & 0 deletions deploy/templates/grafana/grafani-ini.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
namespace: {{ .Release.Namespace }}
name: acs-grafana-config
data:
auth_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/auth
token_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/token
api_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/userinfo
3 changes: 2 additions & 1 deletion deploy/templates/hooks/post-delete.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ spec:
- |
echo "Starting cleanup..."
for i in $(kubectl -n {{ .Release.Namespace }} get kerberos-keys -o name); do kubectl -n {{ .Release.Namespace }} patch $i --type=json -p='[{"op": "remove", "path": "/metadata/finalizers"}]'; done
for i in $(kubectl -n {{ .Release.Namespace }} get localsecrets -o name); do kubectl -n {{ .Release.Namespace }} patch $i --type=json -p='[{"op": "remove", "path": "/metadata/finalizers"}]'; done
echo "Cleanup complete!"
restartPolicy: Never
backoffLimit: 3
Expand Down Expand Up @@ -45,7 +46,7 @@ metadata:
"helm.sh/hook-delete-policy": before-hook-creation
rules:
- apiGroups: [ "factoryplus.app.amrc.co.uk" ]
resources: [ "kerberos-keys" ]
resources: [ "kerberos-keys", "localsecrets" ]
verbs: [ "get", "list", "patch" ] # Specify only necessary actions

---
Expand Down
4 changes: 2 additions & 2 deletions deploy/templates/identity/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ metadata:
name: krb-keys-operator
rules:
- apiGroups: [factoryplus.app.amrc.co.uk]
resources: [kerberos-keys]
resources: [kerberos-keys, localsecrets]
verbs: [list, get, watch, patch]
- apiGroups: [factoryplus.app.amrc.co.uk]
resources: [kerberos-keys/status]
resources: [kerberos-keys/status, localsecrets/status]
verbs: [list, get, create, update, delete, watch, patch]
- apiGroups: [""]
resources: [secrets]
Expand Down
11 changes: 11 additions & 0 deletions deploy/templates/openid/local-secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{if .Values.openid.enabled}}
apiVersion: factoryplus.app.amrc.co.uk/v1
kind: LocalSecret
metadata:
namespace: {{ .Release.Namespace }}
name: "keycloak-grafana-client"
spec:
format: Password
secret: "keycloak-grafana-client"
key: "grafana"
{{end }}
23 changes: 23 additions & 0 deletions deploy/templates/openid/openid-ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{{ if .Values.openid.enabled }}
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: openid-ingressroute
namespace: {{ .Release.Namespace }}
spec:
entryPoints:
- {{ .Values.acs.secure | ternary "websecure" "web" }}
routes:
- match: Host(`openid.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}}`)
kind: Rule
services:
- name: openid
port: 80
namespace: {{ .Release.Namespace }}
{{- if .Values.acs.secure }}
tls:
secretName: {{ coalesce .Values.openid.tlsSecretName .Values.acs.tlsSecretName }}
domains:
- main: openid.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}}
{{- end -}}
{{- end -}}
Loading