diff --git a/acs-service-setup/lib/openid-realm.js b/acs-service-setup/lib/openid-realm.js new file mode 100644 index 000000000..e990763ff --- /dev/null +++ b/acs-service-setup/lib/openid-realm.js @@ -0,0 +1,575 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { URLSearchParams } from "url"; +import { setTimeout } from "timers/promises"; + +/** + * Create the startup OpenID realm `factory_plus`. + * + * @async + * @param {ServiceSetup} service_setup - Contains the configuration for setting up the realm. + * @returns {Promise} Resolves when the realm creation process finishes. + */ +export async function create_openid_realm(service_setup) { + await new RealmSetup(service_setup).run(); +} + +class FetchError extends Error { + constructor (status, msg) { + super(`Fetch error: ${status}: ${msg}`); + this.status = status; + } + + /** Construct and throw a FetchError. + * @async + * @arg response A Fetch Response object + */ + static async throwOf (response) { + const msg = await response.text(); + throw new this(response.status, msg); + } + + /** Swallow expected errors. + * This is intended to be used with Promise.catch. The function + * returned will rethrow unexpected errors and return undefined for + * expected errors. + * @arg codes Expected error status codes + * @returns A catch function + */ + static expect (...codes) { + return err => { + if (!(err instanceof this)) + throw err; + if (!codes.includes(err.status)) + throw err; + return; + }; + } +} + +class RealmSetup { + constructor(service_setup) { + const { fplus } = service_setup; + this.log = fplus.debug.bound("oauth"); + + this.fplus = fplus; + this.username = "_bootstrap"; + this.realm = "factory_plus"; + this.base_url = service_setup.acs_config.domain; + this.secure = service_setup.acs_config.secure; + this.acs_realm = service_setup.acs_config.realm; + this.config = service_setup.config; + } + + /** Generate a URL to the OpenID server. + * @param {string} path - The path for the URL + * @returns {string} A full URL + */ + url (path) { + return `http${this.secure}://openid.${this.base_url}/${path}`; + } + + /** Perform a fetch with error handling. + * 5xx errors will delay and retry. + * Other HTTP errors will throw a FetchError. + * @async + * @arg request - Request object + * @returns {Response} A Fetch Response + */ + async try_fetch (request) { + const response = await fetch(request); + + if (response.ok) + return response; + + if (response.status >= 500 && response.status < 600) { + await setTimeout(10000); + this.log("Retrying %s", request.url); + return this.try_fetch(new Request(request)); + } + + await FetchError.throwOf(response); + } + + /** Fetch a token. + * @async + * @arg realm - The realm name. + * @arg params - An object of parameters for the token request. + */ + async fetch_token (realm, params) { + const token_url = this.url(`realms/${realm}/protocol/openid-connect/token`); + + this.log("Token request [%s]: %s", params.grant_type, token_url); + + const response = await this.try_fetch(new Request(token_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(params), + })); + + return response.json(); +} + + /** Make a request with a token. + * 5xx errors will delay and retry. + * If we get a 401 we retry, once, with a new token. + * Other HTTP errors will throw. + * The token method is called on `this`. + * The Request object will be modified with the token header. + * @arg tokensrc - Async function to get a token. + * @arg request - Request object + * @returns A Response + */ + async with_token (tokensrc, request, retry) { + const token = await tokensrc.call(this, retry); + + request.headers.set("Authorization", `Bearer ${token}`); + const doit = this.try_fetch(request); + const response = await (retry ? doit : doit.catch(FetchError.expect(401))); + + if (!response) + return this.with_token(tokensrc, request, true); + + return response; + } + + /** Create an OpenID resouce. + * Performs a request with the appropriate token. + * Success returns true. 409 returns false. Other errors throw. + * @arg opts Options for creation + * @returns Did we create the resource? + */ + async openid_create (opts) { + const url = this.url(opts.path); + this.log("Attempting to create %s at %s", opts.name, url); + + const request = new Request(url, { + method: opts.method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts.body), + }); + + const created = await this.with_token(opts.tokensrc, request) + .catch(FetchError.expect(409)); + + if (created) + this.log("Created %s: %o", opts.name, opts.body); + else + this.log("Can't create %s: already exists", opts.name); + + return !!created; + } + + async client_secret (name) { + const secret_path = path.join("/etc/secret", name); + const content = await fs.readFile(secret_path, "utf8"); + return content.trim(); + } + + /** + * 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} + */ + async run() { + this.password = await this.client_secret("_bootstrap"); + this.admin_cli_secret = await this.client_secret("_admin"); + + await this.create_basic_realm(); + this.admin_user_id = await this.create_admin_user(); + + const client_configs = Object.entries(this.config.openidClients); + const enabled_clients = client_configs.filter( + ([name, client]) => client.enabled === true, + ); + for (const [clientId, client] of enabled_clients) { + if (!client.builtin) + await this.create_client(clientId, client); + await this.create_client_role_mappings(clientId, client.adminRoles); + } + } + + /** + * 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. + * @returns {Promise} Resolves when the realm is created. + */ + async create_basic_realm() { + const admin_cli_id = crypto.randomUUID(); + + const base_realm = { + id: crypto.randomUUID(), + realm: this.realm, + displayName: "Factory+", + displayNameHtml: '
Factory+
', + enabled: true, + components: { + "org.keycloak.storage.UserStorageProvider": [ + { + id: crypto.randomUUID(), + name: "Kerberos", + providerId: "kerberos", + subComponents: {}, + config: { + serverPrincipal: [ + `HTTP/openid.${this.base_url}@${this.acs_realm}`, + ], + allowPasswordAuthentication: ["true"], + debug: ["true"], + keyTab: ["/etc/keytabs/server"], + cachePolicy: ["DEFAULT"], + updateProfileFirstLogin: ["true"], + kerberosRealm: [this.acs_realm], + enabled: ["true"], + }, + }, + ], + }, + clients: [ + { + id: admin_cli_id, + clientId: "admin-cli", + name: "Admin service account", + enabled: true, + clientAuthenticatorType: "client-secret", + secret: this.admin_cli_secret, + directAccessGrantsEnabled: true, + serviceAccountsEnabled: true, + authorizationServicesEnabled: true, + }, + ], + roles: { + client: { + "admin-cli": [ + { + id: crypto.randomUUID(), + name: "manage-users", + description: "", + composite: false, + clientRole: true, + containerId: admin_cli_id, + attributes: {}, + }, + { + id: crypto.randomUUID(), + name: "uma_protection", + composite: false, + clientRole: true, + containerId: admin_cli_id, + attributes: {}, + }, + { + id: crypto.randomUUID(), + name: "manage-realm", + description: "", + composite: false, + clientRole: true, + containerId: admin_cli_id, + attributes: {}, + }, + ], + }, + }, + users: [ + { + id: crypto.randomUUID(), + username: "service-account-admin-cli", + emailVerified: false, + enabled: true, + serviceAccountClientId: "admin-cli", + realmRoles: ["default-roles-factory_plus"], + clientRoles: { + "realm-management": ["manage-realm", "manage-users"], + "admin-cli": ["uma_protection"], + }, + }, + ], + }; + + await this.openid_create({ + name: `realm`, + tokensrc: this.get_initial_access_token, + method: "POST", + path: `admin/realms`, + body: base_realm, + }); + } + + /** + * 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. + * @returns {Promise} Resolves when the client is created. + */ + async create_client(clientId, client_representation) { + const client_secret = await this.client_secret(clientId); + + const host = client_representation.redirectHost ?? clientId; + const url = `http${this.secure}://${host}.${this.base_url}`; + + const client = { + id: clientId, + clientId, + name: client_representation.name, + rootUrl: url, + adminUrl: url, + baseUrl: url, + enabled: true, + secret: client_secret, + defaultRoles: client_representation.defaultRoles, + redirectUris: [`${url}${client_representation.redirectPath}`], + attributes: { + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": url, + }, + protocolMappers: [ + { + name: "client roles", + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-client-role-mapper", + consentRequired: false, + config: { + "introspection.token.claim": "true", + multivalued: "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "usermodel.clientRoleMapping.clientId": + client_representation.clientId, + }, + }, + ], + }; + + await this.openid_create({ + name: `realm client ${clientId}`, + tokensrc: this.get_initial_access_token, + path: `admin/realms/${this.realm}/clients`, + method: "POST", + body: client, + }); + + if (client_representation.roles) + await this.create_client_roles(client.id, client_representation.roles, host); + } + + /** + * Create roles for a client by POSTing to the OpenID service. + * + * @async + * @param {string} clientId - The UUID of the client to create roles for. + * @param {Array} roles - Array of role configurations. + * @param {string} containerId - The container ID for the roles (name of the client). + * @returns {Promise} Resolves when all roles are created. + */ + async create_client_roles(clientId, roles, containerId) { + for (const role of roles) { + const role_representation = { + id: crypto.randomUUID(), + name: role.name, + composite: false, + clientRole: true, + containerId: containerId, + }; + + await this.openid_create({ + name: `role ${role.name} for client ${clientId}`, + tokensrc: this.get_initial_access_token, + path: `admin/realms/${this.realm}/clients/${clientId}/roles`, + method: "POST", + body: role_representation, + }); + } + } + + /** + * Create the admin user in the OpenID service. + * @async + * @returns {Promise} Resolves when the user is created. + */ + async create_admin_user() { + const { username, password } = this.fplus.opts; + + this.log("Setting up %s account", username); + /* Attempt a login as the admin user. This will fail if the user + * profile hasn't been created yet but will create the user on the + * OpenID side as a side-effect. We need to use the client secret + * here as OAuth doesn't appear to provide any way for users to log + * in on their own account. */ + await this.fetch_token(this.realm, { + grant_type: "password", + client_id: "admin-cli", + client_secret: this.admin_cli_secret, + username, + password, + }).catch(FetchError.expect(400)); + + /* Fetch the user id (allocated by Keycloak) */ + const id = await this.get_user_id(username); + + const admin_user = { + firstName: "Admin", + lastName: "User", + email: `${username}@${this.acs_realm}`, + emailVerified: false, + }; + + /* Update the profile */ + await this.openid_create({ + name: `admin profile`, + tokensrc: this.get_user_management_token, + method: "PUT", + path: `admin/realms/${this.realm}/users/${id}`, + body: admin_user, + }); + + return id; + } + + /** + * Create client-level role mapping for the admin user. + + * @async + * @param {string} client_id - The ID of the client containing the role. + * @param {string} role_name - The name of the role. + * @returns {Promise} Resolves when the mapping is created. + */ + async create_client_role_mappings (client_name, roles) { + const { admin_user_id, realm } = this; + + this.log("Checking role mappings for %s", client_name); + + const fetch_info = path => this.with_token( + this.get_user_management_token, + new Request(this.url(path)) + ).then(res => res.json()); + + const realm_p = `admin/realms/${realm}`; + const clients = await fetch_info(`${realm_p}/clients?clientId=${client_name}`); + + if (clients.length != 1) + throw new Error(`Can't find client ${client_name}`); + + const client_id = clients[0].id; + this.log("Found client-id %s", client_id); + + const map_p = `${realm_p}/users/${admin_user_id}/role-mappings/clients/${client_id}`; + + const want = new Set(roles); + + const assigned = await fetch_info(map_p); + const have = assigned.map(r => r.name).filter(n => want.has(n)); + for (const name of have) { + this.log("Admin already has role %s for %s", name, client_id); + want.delete(name); + } + + if (!want.size) return; + + const available = await fetch_info(`${map_p}/available`); + const assign = available.filter(r => want.has(r.name)); + + if (assign.length != want.size) { + this.log("Required roles: %s", [...want].join(", ")); + this.log("Available roles: %s", assign.map(r => r.name).join(", ")); + throw new Error("Not all required roles are available"); + } + + await this.openid_create({ + name: `role mapping for ${client_id}`, + tokensrc: this.get_user_management_token, + method: "POST", + path, + body: assign, + }); + } + + /** + * Get the ID of the admin user. This isn't the same as the ID given during creation. + * + * @async + * @returns {Promise} Resolves to the admin user ID. + */ + async get_user_id(username) { + const user_query_url = this.url(`admin/realms/${this.realm}/users?exact=true&username=${username}`); + + const response = await this.with_token( + this.get_user_management_token, + new Request(user_query_url)); + + const data = await response.json(); + const id = data[0]?.id; + if (id == null) + throw new Error(`User ${username} doesn't exist`); + return id; + } + + /** + * 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 {boolean} force - Do we need a fresh token? + * @returns {Promise} Resolves to an access token + */ + async get_initial_access_token(force) { + const { access_token, refresh_token } = this; + + if (access_token && !force) + return access_token; + + const params = { + client_id: "admin-cli", + ...(refresh_token ? { + grant_type: "refresh_token", + refresh_token, + } : { + grant_type: "password", + username: this.username, + password: this.password, + }), + }; + + const data = await this.fetch_token("master", params); + + this.access_token = data.access_token; + this.refresh_token = data.refresh_token; + return data.access_token; + } + + /** + * Fetch a token for the user management client in the Factory+ realm. + * + * Sets the `user_management_token` property of `OAuthRealm` as a side effect. + * + * @async + * @param {boolean} force - Do we need a fresh token? + * @returns {Promise} Resolves to an access token. + */ + async get_user_management_token(force) { + if (this.user_management_token && !force) + return this.user_management_token; + + const data = await this.fetch_token(this.realm, { + grant_type: "client_credentials", + client_id: "admin-cli", + client_secret: this.admin_cli_secret, + }); + + this.user_management_token = data.access_token; + return data.access_token; + } +} diff --git a/acs-service-setup/lib/service-setup.js b/acs-service-setup/lib/service-setup.js index 75ee6caa1..0ad5e47c9 100644 --- a/acs-service-setup/lib/service-setup.js +++ b/acs-service-setup/lib/service-setup.js @@ -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) { @@ -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"); } } diff --git a/deploy/crds/local-secret.yaml b/deploy/crds/local-secret.yaml new file mode 100644 index 000000000..d2a142b44 --- /dev/null +++ b/deploy/crds/local-secret.yaml @@ -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: {} diff --git a/deploy/templates/grafana/grafana-admin-user.yaml b/deploy/templates/grafana/grafana-admin-user.yaml index 0597cbccb..ee08f14d7 100644 --- a/deploy/templates/grafana/grafana-admin-user.yaml +++ b/deploy/templates/grafana/grafana-admin-user.yaml @@ -1,17 +1,11 @@ {{ if .Values.grafana.enabled }} -{{- if not (lookup "v1" "Secret" .Release.Namespace "grafana-admin-user") }} - apiVersion: v1 kind: Secret metadata: name: "grafana-admin-user" namespace: {{ .Release.Namespace }} - annotations: - "helm.sh/resource-policy": "keep" type: Opaque -data: - admin-user: {{ (printf "admin@%s" (.Values.identity.realm | required "values.identity.realm is required!") | b64enc) | quote }} - admin-password: {{ (printf "" | b64enc) | quote }} - -{{- end }} +stringData: + admin-user: "_bootstrap" + admin-password: {{ randAlphaNum 20 | quote }} {{- end -}} diff --git a/deploy/templates/grafana/grafana-ingress.yaml b/deploy/templates/grafana/grafana-ingress.yaml index 43801b005..e103bfc84 100644 --- a/deploy/templates/grafana/grafana-ingress.yaml +++ b/deploy/templates/grafana/grafana-ingress.yaml @@ -10,8 +10,6 @@ spec: routes: - match: Host(`grafana.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}}`) kind: Rule - middlewares: - - name: basic-auth services: - name: acs-grafana port: 80 diff --git a/deploy/templates/grafana/grafana-ini.yaml b/deploy/templates/grafana/grafana-ini.yaml new file mode 100644 index 000000000..f1f65bdc0 --- /dev/null +++ b/deploy/templates/grafana/grafana-ini.yaml @@ -0,0 +1,11 @@ +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 + root_url: {{ .Values.acs.secure | ternary "https://" "http://" }}grafana.{{.Values.acs.baseUrl}} + signout_redirect_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/logout?post_logout_redirect_uri={{ .Values.acs.secure | ternary "https://" "http://" }}grafana.{{.Values.acs.baseUrl}}&client_id=grafana diff --git a/deploy/templates/hooks/post-delete.yaml b/deploy/templates/hooks/post-delete.yaml index d3d8ebb87..7c2da7775 100644 --- a/deploy/templates/hooks/post-delete.yaml +++ b/deploy/templates/hooks/post-delete.yaml @@ -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 @@ -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 --- diff --git a/deploy/templates/identity/rbac.yaml b/deploy/templates/identity/rbac.yaml index 26b425024..30b82e01e 100644 --- a/deploy/templates/identity/rbac.yaml +++ b/deploy/templates/identity/rbac.yaml @@ -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] diff --git a/deploy/templates/mqtt/mqtt.yaml b/deploy/templates/mqtt/mqtt.yaml index 70f25ed10..da6126b3a 100644 --- a/deploy/templates/mqtt/mqtt.yaml +++ b/deploy/templates/mqtt/mqtt.yaml @@ -57,6 +57,8 @@ spec: - name: VERBOSE value: {{.Values.mqtt.verbosity | quote | required "values.mqtt.verbosity is required!"}} volumeMounts: + # XXX This would be better without the subPath, but we need + # to use a Java system property to move krb5.conf. - mountPath: /etc/krb5.conf name: krb5-conf subPath: krb5.conf diff --git a/deploy/templates/openid/local-secrets.yaml b/deploy/templates/openid/local-secrets.yaml new file mode 100644 index 000000000..884f69af8 --- /dev/null +++ b/deploy/templates/openid/local-secrets.yaml @@ -0,0 +1,35 @@ +{{ if .Values.openid.enabled }} +{{ range $clientName, $client := .Values.serviceSetup.config.openidClients }} +--- +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: LocalSecret +metadata: + namespace: {{ $.Release.Namespace }} + name: "keycloak-client-{{ $clientName }}" +spec: + format: Password + secret: "keycloak-clients" + key: "{{ $clientName }}" +{{ end }} +--- +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: LocalSecret +metadata: + namespace: {{ $.Release.Namespace }} + name: "keycloak-admin-bootstrap" +spec: + format: Password + secret: "keycloak-clients" + key: "_bootstrap" +--- +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: LocalSecret +metadata: + namespace: {{ $.Release.Namespace }} + name: "keycloak-admin-admin" +spec: + format: Password + secret: "keycloak-clients" + key: "_admin" + +{{ end }} diff --git a/deploy/templates/openid/openid-ingress.yaml b/deploy/templates/openid/openid-ingress.yaml new file mode 100644 index 000000000..900e648a2 --- /dev/null +++ b/deploy/templates/openid/openid-ingress.yaml @@ -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 -}} diff --git a/deploy/templates/openid/openid.yaml b/deploy/templates/openid/openid.yaml new file mode 100644 index 000000000..29c57e1a9 --- /dev/null +++ b/deploy/templates/openid/openid.yaml @@ -0,0 +1,111 @@ +{{ if .Values.openid.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openid + namespace: {{ .Release.Namespace }} + labels: + component: openid +spec: + strategy: + type: Recreate + # We cannot allow more replicas with a RWO PVC backend + replicas: 1 + selector: + matchLabels: + component: openid + template: + metadata: + labels: + component: openid + factory-plus.service: openid + spec: + {{- with .Values.acs.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: openid + image: "{{ .Values.openid.image.repository }}:{{ .Values.openid.image.tag }}" + imagePullPolicy: {{ .Values.openid.image.pullPolicy }} + args: ["start-dev"] + env: + - name: KEYCLOAK_ADMIN + value: "_bootstrap" + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-clients + key: _bootstrap + - name: KC_PROXY + value: "edge" + - name: KC_HEALTH_ENABLED + value: "true" + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 9000 + volumeMounts: + # XXX This would be better without the subPath, but we need + # to use a Java system property to move krb5.conf. + - name: krb5-conf + mountPath: /etc/krb5.conf + subPath: krb5.conf + - name: openid-keytabs + mountPath: /etc/keytabs + - name: data + mountPath: /opt/keycloak/data + volumes: + - name: krb5-conf + configMap: + name: krb5-conf + - name: openid-keytabs + secret: + secretName: openid-keytabs + - name: data + persistentVolumeClaim: + claimName: keycloak-data +--- +# XXX Could we use Postgres here instead? +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: keycloak-data + namespace: {{ .Release.Namespace }} + labels: + component: openid +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: openid + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: http + port: 80 + targetPort: 8080 + selector: + factory-plus.service: openid +--- +apiVersion: factoryplus.app.amrc.co.uk/v1 +kind: KerberosKey +metadata: + name: http.openid + namespace: {{ .Release.Namespace }} +spec: + type: Random + principal: HTTP/openid.{{ .Release.Namespace }}.svc.cluster.local@{{ .Values.identity.realm | required "values.identity.realm is required!" }} + additionalPrincipals: + - HTTP/openid.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}}@{{ .Values.identity.realm | required "values.identity.realm is required!" }} + secret: openid-keytabs/server +{{- end }} diff --git a/deploy/templates/service-setup.yaml b/deploy/templates/service-setup.yaml index 67c4d517f..84658e00b 100644 --- a/deploy/templates/service-setup.yaml +++ b/deploy/templates/service-setup.yaml @@ -24,6 +24,9 @@ spec: name: krb5-conf - name: manager-ccache-storage emptyDir: { } + - name: client-secrets + secret: + secretName: keycloak-clients initContainers: - name: service-setup {{ include "amrc-connectivity-stack.image" (list . .Values.serviceSetup) | indent 10 }} @@ -32,6 +35,10 @@ spec: value: http://directory.{{ .Release.Namespace }}.svc.cluster.local - name: SERVICE_USERNAME value: admin + # Currently the openid realm setup relies on having the + # admin@ credentials available, with a password rather than + # a keytab. If service-setup is ever moved over to using its + # own account there will need to be changes to that code. - name: SERVICE_PASSWORD valueFrom: secretKeyRef: @@ -53,12 +60,15 @@ spec: "secure" (.Values.acs.secure | ternary "s" "") "realm" .Values.identity.realm "directory" - (include "amrc-connectivity-stack.external-url" + (include "amrc-connectivity-stack.external-url" (list . "directory")) | toRawJson | quote }} volumeMounts: - mountPath: /data name: git-checkouts + - name: client-secrets + mountPath: /etc/secret + readOnly: true - name: edge-helm-charts {{ include "amrc-connectivity-stack.image" (list . .Values.edgeHelm) | indent 10 }} env: @@ -78,7 +88,7 @@ spec: - name: manager image: "{{ include "amrc-connectivity-stack.image-name" (list . .Values.manager ) }}-backend" imagePullPolicy: {{ .Values.manager.image.pullPolicy }} - command: + command: - /bin/sh - "-c" - | diff --git a/deploy/values.yaml b/deploy/values.yaml index aee74c27d..1b212c9ad 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -131,6 +131,34 @@ serviceSetup: helmChart: # Chart to deploy an edge cluster #cluster: null + openidClients: + grafana: + enabled: true + name: "Grafana" + redirectPath: "/login/generic_oauth" + roles: + - name: "viewer" + - name: "editor" + - name: "admin" + - name: "grafanaAdmin" + defaultRoles: [viewer] + adminRoles: [grafanaAdmin] + realm-management: + enabled: true + builtin: true + adminRoles: + - view-realm + - view-users + - view-clients + - view-events + - manage-realm + - manage-users + - create-client + - manage-clients + - manage-events + - view-identity-providers + - manage-identity-providers + - impersonation edgeHelm: enabled: true @@ -249,7 +277,7 @@ shell: repository: acs-krb-utils # XXX This should probably be included in acs-krb-utils -curl: +curl: image: registry: docker.io repository: appropriate/curl @@ -346,13 +374,45 @@ grafana: admin: existingSecret: grafana-admin-user grafana.ini: + server: + root_url: "$__file{/etc/acs-config/root_url}" + auth: + disable_login_form: true auth.basic: enabled: false - auth.proxy: + auth.generic_oauth: enabled: true - header_name: X-Auth-Principal - header_property: username - auto_sign_up: true + name: "Factory+" + allow_sign_up: true + client_id: grafana + client_secret: $__file{/etc/secrets/auth_generic_oauth/grafana} + scopes: openid profile email + auth_url: $__file{/etc/acs-config/auth_url} + token_url: $__file{/etc/acs-config/token_url} + api_url: $__file{/etc/acs-config/api_url} + signout_redirect_url: $__file{/etc/acs-config/signout_redirect_url} + role_attribute_path: > + contains(roles[*], 'grafanaAdmin') && 'GrafanaAdmin' + || contains(roles[*], 'admin') && 'Admin' + || contains(roles[*], 'editor') && 'Editor' + || 'Viewer' + allow_assign_grafana_admin: true + extraConfigmapMounts: + - name: ini-config + mountPath: /etc/acs-config + configMap: acs-grafana-config + subPath: "" + readOnly: true + optional: false + extraSecretMounts: + - name: keycloak-client-mount + secretName: keycloak-clients + defaultMode: 0440 + mountPath: /etc/secrets/auth_generic_oauth + readOnly: true + items: + - key: grafana + path: grafana sidecar: datasources: enabled: true @@ -403,5 +463,12 @@ influxdb2: pdb: create: false +openid: + enabled: true + image: + repository: quay.io/keycloak/keycloak + tag: 26.1.1 + pullPolicy: IfNotPresent + cert-manager: fullnameOverride: "cert-manager"