diff --git a/app/src/main/java/io/apicurio/registry/rest/v3/SystemResourceImpl.java b/app/src/main/java/io/apicurio/registry/rest/v3/SystemResourceImpl.java index f1dadf6890..0e3c19b0ef 100644 --- a/app/src/main/java/io/apicurio/registry/rest/v3/SystemResourceImpl.java +++ b/app/src/main/java/io/apicurio/registry/rest/v3/SystemResourceImpl.java @@ -1,16 +1,24 @@ package io.apicurio.registry.rest.v3; +import java.util.HashMap; +import java.util.Map; + import io.apicurio.common.apps.core.System; import io.apicurio.common.apps.logging.Logged; +import io.apicurio.registry.auth.AuthConfig; import io.apicurio.registry.auth.Authorized; import io.apicurio.registry.auth.AuthorizedLevel; import io.apicurio.registry.auth.AuthorizedStyle; +import io.apicurio.registry.limits.RegistryLimitsConfiguration; import io.apicurio.registry.metrics.health.liveness.ResponseErrorLivenessCheck; import io.apicurio.registry.metrics.health.readiness.ResponseTimeoutReadinessCheck; -import io.apicurio.registry.limits.RegistryLimitsConfiguration; import io.apicurio.registry.rest.v3.beans.Limits; import io.apicurio.registry.rest.v3.beans.SystemInfo; - +import io.apicurio.registry.rest.v3.beans.UserInterfaceConfig; +import io.apicurio.registry.rest.v3.beans.UserInterfaceConfigAuth; +import io.apicurio.registry.rest.v3.beans.UserInterfaceConfigFeatures; +import io.apicurio.registry.rest.v3.beans.UserInterfaceConfigUi; +import io.apicurio.registry.ui.UserInterfaceConfigProperties; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.interceptor.Interceptors; @@ -22,6 +30,12 @@ public class SystemResourceImpl implements SystemResource { @Inject System system; + + @Inject + AuthConfig authConfig; + + @Inject + UserInterfaceConfigProperties uiConfig; @Inject RegistryLimitsConfiguration registryLimitsConfiguration; @@ -44,6 +58,7 @@ public SystemInfo getSystemInfo() { * @see io.apicurio.registry.rest.v3.SystemResource#getResourceLimits() */ @Override + @Authorized(style=AuthorizedStyle.None, level=AuthorizedLevel.None) public Limits getResourceLimits() { var limitsConfig = registryLimitsConfiguration; var limits = new Limits(); @@ -61,4 +76,41 @@ public Limits getResourceLimits() { limits.setMaxRequestsPerSecondCount(limitsConfig.getMaxRequestsPerSecondCount()); return limits; } + + /** + * @see io.apicurio.registry.rest.v3.SystemResource#getUIConfig() + */ + @Override + @Authorized(style=AuthorizedStyle.None, level=AuthorizedLevel.None) + public UserInterfaceConfig getUIConfig() { + return UserInterfaceConfig.builder() + .ui(UserInterfaceConfigUi.builder() + .contextPath(uiConfig.contextPath) + .navPrefixPath(uiConfig.navPrefixPath) + .oaiDocsUrl(uiConfig.docsUrl) + .build()) + .auth(uiAuthConfig()) + .features(UserInterfaceConfigFeatures.builder() + .readOnly("true".equals(uiConfig.featureReadOnly)) + .breadcrumbs("true".equals(uiConfig.featureBreadcrumbs)) + .roleManagement(authConfig.isRbacEnabled()) + .settings("true".equals(uiConfig.featureSettings)) + .build()) + .build(); + } + + private UserInterfaceConfigAuth uiAuthConfig() { + UserInterfaceConfigAuth rval = new UserInterfaceConfigAuth(); + rval.setObacEnabled(authConfig.isObacEnabled()); + rval.setRbacEnabled(authConfig.isRbacEnabled()); + rval.setType(authConfig.isAuthEnabled() ? UserInterfaceConfigAuth.Type.oidc : UserInterfaceConfigAuth.Type.none); + if (authConfig.isAuthEnabled()) { + Map options = new HashMap<>(); + options.put("url", uiConfig.authOidcUrl); + options.put("redirectUri", uiConfig.authOidcRedirectUri); + options.put("clientId", uiConfig.authOidcClientId); + rval.setOptions(options); + } + return rval; + } } diff --git a/app/src/main/java/io/apicurio/registry/ui/UserInterfaceConfigProperties.java b/app/src/main/java/io/apicurio/registry/ui/UserInterfaceConfigProperties.java new file mode 100644 index 0000000000..1858cb1d95 --- /dev/null +++ b/app/src/main/java/io/apicurio/registry/ui/UserInterfaceConfigProperties.java @@ -0,0 +1,42 @@ +package io.apicurio.registry.ui; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.apicurio.common.apps.config.Info; +import jakarta.inject.Singleton; + +@Singleton +public class UserInterfaceConfigProperties { + + @ConfigProperty(name = "registry.ui.contextPath", defaultValue = "/") + @Info(category = "ui", description = "Context path of the UI", availableSince = "3.0.0") + public String contextPath; + @ConfigProperty(name = "registry.ui.navPrefixPath", defaultValue = "/") + @Info(category = "ui", description = "Navigation prefix for all UI paths", availableSince = "3.0.0") + public String navPrefixPath; + @ConfigProperty(name = "registry.ui.docsUrl", defaultValue = "/docs/") + @Info(category = "ui", description = "URL of the Documentation component", availableSince = "3.0.0") + public String docsUrl; + + + @ConfigProperty(name = "registry.auth.url.configured") + public String authOidcUrl; + @ConfigProperty(name = "registry.ui.auth.oidc.redirectUri", defaultValue = "/") + @Info(category = "ui", description = "The OIDC redirectUri", availableSince = "3.0.0") + public String authOidcRedirectUri; + @ConfigProperty(name = "registry.ui.auth.oidc.clientId", defaultValue = "apicurio-registry-ui") + @Info(category = "ui", description = "The OIDC clientId", availableSince = "3.0.0") + public String authOidcClientId; + + + @ConfigProperty(name = "registry.ui.features.readOnly", defaultValue = "false") + @Info(category = "ui", description = "Enabled to set the UI to read-only mode", availableSince = "3.0.0") + public String featureReadOnly; + @ConfigProperty(name = "registry.ui.features.breadcrumbs", defaultValue = "true") + @Info(category = "ui", description = "Enabled to show breadcrumbs in the UI", availableSince = "3.0.0") + public String featureBreadcrumbs; + @ConfigProperty(name = "registry.ui.features.settings", defaultValue = "true") + @Info(category = "ui", description = "Enabled to show the Settings tab in the UI", availableSince = "3.0.0") + public String featureSettings; + +} diff --git a/common/src/main/resources/META-INF/openapi.json b/common/src/main/resources/META-INF/openapi.json index 74c209878d..98fc80dfcb 100644 --- a/common/src/main/resources/META-INF/openapi.json +++ b/common/src/main/resources/META-INF/openapi.json @@ -3380,6 +3380,33 @@ } ] }, + "/system/uiConfig": { + "summary": "Get UI configuration", + "description": "This endpoint is used by the user interface to retrieve UI specific configuration\nin a JSON payload. This allows the UI and the backend to be configured in the \nsame place (the backend process/pod). When the UI loads, it will make an API call\nto this endpoint to determine what UI features and options are configured.", + "get": { + "tags": [ + "System" + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserInterfaceConfig" + } + } + }, + "description": "The UI config." + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + }, + "operationId": "getUIConfig", + "summary": "Get UI config", + "description": "Returns the UI configuration properties for this server. The registry UI can be\nconnected to a backend using just a URL. The rest of the UI configuration can then\nbe fetched from the backend using this operation. This allows UI and backend to\nboth be configured in the same place.\n\nThis operation may fail for one of the following reasons:\n\n* A server error occurred (HTTP error `500`)\n" + } + }, "x-codegen-contextRoot": "/apis/registry/v3" }, "components": { @@ -4711,6 +4738,181 @@ } ] } + }, + "UserInterfaceConfig": { + "title": "Root Type for UserInterfaceConfig", + "description": "Defines the user interface configuration data type.", + "required": [ + "auth" + ], + "type": "object", + "properties": { + "ui": { + "$ref": "#/components/schemas/UserInterfaceConfigUi", + "properties": { + "contextPath": { + "type": "string" + }, + "navPrefixPath": { + "type": "string" + }, + "oaiDocsUrl": { + "type": "string" + } + } + }, + "auth": { + "$ref": "#/components/schemas/UserInterfaceConfigAuth", + "properties": { + "type": { + "type": "string" + }, + "rbacEnabled": { + "type": "boolean" + }, + "obacEnabled": { + "type": "boolean" + }, + "options": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "clientId": { + "type": "string" + } + } + } + } + }, + "features": { + "$ref": "#/components/schemas/UserInterfaceConfigFeatures", + "properties": { + "readOnly": { + "type": "boolean" + }, + "breadcrumbs": { + "type": "boolean" + }, + "roleManagement": { + "type": "boolean" + }, + "settings": { + "type": "boolean" + } + } + } + }, + "example": { + "ui": { + "contextPath": "/", + "navPrefixPath": "/", + "oaiDocsUrl": "https://registry.apicur.io/docs" + }, + "auth": { + "type": "oidc", + "rbacEnabled": true, + "obacEnabled": false, + "options": { + "url": "https://auth.apicur.io/realms/apicurio", + "redirectUri": "http://registry.apicur.io", + "clientId": "apicurio-registry-ui" + } + }, + "features": { + "readOnly": false, + "breadcrumbs": true, + "roleManagement": false, + "settings": true + } + } + }, + "UserInterfaceConfigAuth": { + "title": "Root Type for UserInterfaceConfigAuth", + "description": "", + "required": [ + "obacEnabled", + "rbacEnabled" + ], + "type": "object", + "properties": { + "type": { + "enum": [ + "none", + "oidc" + ], + "type": "string" + }, + "rbacEnabled": { + "type": "boolean" + }, + "obacEnabled": { + "type": "boolean" + }, + "options": { + "$ref": "#/components/schemas/Properties" + } + }, + "example": { + "type": "oidc", + "rbacEnabled": true, + "obacEnabled": false, + "options": { + "url": "https://auth.apicur.io/realms/apicurio", + "redirectUri": "https://registry.apicur.io", + "clientId": "registry-ui" + } + } + }, + "UserInterfaceConfigFeatures": { + "title": "Root Type for UserInterfaceConfigFeatures", + "description": "", + "type": "object", + "properties": { + "readOnly": { + "type": "boolean" + }, + "breadcrumbs": { + "type": "boolean" + }, + "roleManagement": { + "type": "boolean" + }, + "settings": { + "type": "boolean" + } + }, + "example": { + "readOnly": false, + "breadcrumbs": true, + "roleManagement": false, + "settings": true + } + }, + "UserInterfaceConfigUi": { + "title": "Root Type for UserInterfaceConfigUi", + "description": "", + "type": "object", + "properties": { + "contextPath": { + "type": "string" + }, + "navPrefixPath": { + "type": "string" + }, + "oaiDocsUrl": { + "type": "string" + } + }, + "example": { + "contextPath": "/", + "navPrefixPath": "/", + "oaiDocsUrl": "https://registry.apicur.io/docs" + } } }, "responses": { diff --git a/docs/generateAllConfigPartial.java b/docs/generateAllConfigPartial.java index c8ffa69135..a753a0d676 100644 --- a/docs/generateAllConfigPartial.java +++ b/docs/generateAllConfigPartial.java @@ -20,6 +20,7 @@ public class generateAllConfigPartial { private static Map allConfiguration = new HashMap(); + private static Set skipProperties = Set.of("registry.auth.url.configured"); static class Option { final String name; @@ -152,6 +153,9 @@ public static Map extractConfigurations(String jarFile, Map = () => { type: config.authType() as "none" | "oidc", options: config.authOptions() }; + if (authConfig.type === "oidc" && (authConfig.options.redirectUri && authConfig.options.redirectUri.startsWith("/"))) { + authConfig.options.redirectUri = window.location.origin + authConfig.options.redirectUri; + } return ( diff --git a/ui/ui-app/src/main.tsx b/ui/ui-app/src/main.tsx index 90e9774c92..28d5318b1a 100644 --- a/ui/ui-app/src/main.tsx +++ b/ui/ui-app/src/main.tsx @@ -1,9 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "@app/App.tsx"; +import { ConfigService, useConfigService } from "@services/useConfigService.ts"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); +// eslint-disable-next-line react-hooks/rules-of-hooks +const config: ConfigService = useConfigService(); +config.fetchAndMergeConfigs().then(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + ); +}); diff --git a/ui/ui-app/src/services/useConfigService.ts b/ui/ui-app/src/services/useConfigService.ts index 4d25d84595..a7eada625b 100644 --- a/ui/ui-app/src/services/useConfigService.ts +++ b/ui/ui-app/src/services/useConfigService.ts @@ -1,3 +1,5 @@ +import { createEndpoint, httpGet } from "@utils/rest.utils.ts"; +import { cloneObject } from "@utils/object.utils.ts"; export enum AlertVariant { success = "success", @@ -106,10 +108,10 @@ export interface Principal { export interface ConfigType { artifacts: ArtifactsConfig; - auth: OidcJsAuthConfig | NoneAuthConfig | GetTokenAuthConfig; + auth?: OidcJsAuthConfig | NoneAuthConfig | GetTokenAuthConfig; principals?: Principal[] | (() => Principal[]); features?: FeaturesConfig; - ui: UiConfig; + ui?: UiConfig; } export interface ApicurioRegistryConfig extends ConfigType { @@ -136,11 +138,70 @@ export function getRegistryConfig(): ApicurioRegistryConfig { return config; } -const registryConfig: ApicurioRegistryConfig = getRegistryConfig(); + +function difference(base: any, overrides: any | undefined): any { + const rval: any = cloneObject(overrides); + + // Remove any properties that exist in base. + Object.getOwnPropertyNames(base).forEach(propertyName => { + if (typeof rval[propertyName] !== "object") { + delete rval[propertyName]; + } + }); + + // Now diff any remaining props that are objects + Object.getOwnPropertyNames(rval).forEach(propertyName => { + const value: any = rval[propertyName]; + const baseValue: any = base[propertyName]; + if (typeof value === "object") { + rval[propertyName] = difference(baseValue, value); + } + }); + + // Now remove any properties with empty object values. + Object.getOwnPropertyNames(rval).forEach(propertyName => { + if (typeof rval[propertyName] === "object" && Object.keys(rval[propertyName]).length === 0) { + delete rval[propertyName]; + } + }); + + return rval; +} + + +function overrideObject(base: any, overrides: any | undefined): any { + if (overrides === undefined) { + return { + ...base + }; + } + const rval: any = {}; + Object.getOwnPropertyNames(base).forEach(propertyName => { + const baseValue: any = base[propertyName]; + const overrideValue: any = overrides[propertyName]; + if (overrideValue) { + if (typeof baseValue === "object" && typeof overrideValue === "object") { + rval[propertyName] = overrideObject(baseValue, overrideValue); + } else { + rval[propertyName] = overrideValue; + } + } else { + rval[propertyName] = baseValue; + } + }); + return rval; +} + +function overrideConfig(base: ApicurioRegistryConfig, overrides: ApicurioRegistryConfig): ApicurioRegistryConfig { + return overrideObject(base, overrides); +} + +let registryConfig: ApicurioRegistryConfig = getRegistryConfig(); export interface ConfigService { + fetchAndMergeConfigs(): Promise; artifactsUrl(): string; uiContextPath(): string|undefined; uiOaiDocsUrl(): string; @@ -161,16 +222,43 @@ export interface ConfigService { export class ConfigServiceImpl implements ConfigService { + public fetchAndMergeConfigs(): Promise { + const endpoint: string = createEndpoint(this.artifactsUrl(), "/system/uiConfig"); + + const localConfig: ApicurioRegistryConfig = registryConfig; + + console.info("[Config] Fetching UI configuration from: ", endpoint); + return httpGet(endpoint).then(remoteConfig => { + console.info("[Config] UI configuration fetched successfully: ", remoteConfig); + // Always use the local config's "artifacts" property (contains the REST API endpoint) + remoteConfig.artifacts = localConfig.artifacts; + // Override the remote config with anything in the local config. Then set the result + // as the new official app config. + registryConfig = overrideConfig(remoteConfig, localConfig); + // Check for extra/unknown local config and warn about it. + const diff: any = difference(remoteConfig, localConfig); + if (Object.keys(diff).length > 0) { + console.warn("[Config] Local config contains unexpected properties: ", diff); + } + }).catch(error => { + console.error("[Config] Error fetching UI configuration: ", error); + console.error("------------------------------------------"); + console.error("[Config] Note: using local UI config only!"); + console.error("------------------------------------------"); + return Promise.resolve(); + }); + } + public artifactsUrl(): string { return registryConfig.artifacts.url || "http://localhost:8080/apis/registry/v3/"; } public uiContextPath(): string|undefined { - return registryConfig.ui.contextPath || "/"; + return registryConfig.ui?.contextPath || "/"; } public uiOaiDocsUrl(): string { - return registryConfig.ui.oaiDocsUrl || "http://localhost:8889"; + return registryConfig.ui?.oaiDocsUrl || "/docs"; } public uiNavPrefixPath(): string|undefined { @@ -210,34 +298,25 @@ export class ConfigServiceImpl implements ConfigService { } public featureSettings(): boolean { - return this.features().settings || false; + return this.features().settings || true; } public authType(): string { - if (!registryConfig.auth || !registryConfig.auth.type) { - return ""; - } - return registryConfig.auth.type; + return registryConfig.auth?.type || "none"; } public authRbacEnabled(): boolean { - if (!registryConfig.auth || !registryConfig.auth.rbacEnabled) { - return false; - } - return registryConfig.auth.rbacEnabled; + return registryConfig.auth?.rbacEnabled || false; } public authObacEnabled(): boolean { - if (!registryConfig.auth || !registryConfig.auth.obacEnabled) { - return false; - } - return registryConfig.auth.obacEnabled; + return registryConfig.auth?.obacEnabled || false; } public authOptions(): OidcJsAuthOptions { if (registryConfig.auth) { const auth: OidcJsAuthConfig = registryConfig.auth as OidcJsAuthConfig; - return auth.options; + return auth.options || {}; } return {} as any; }