diff --git a/README.md b/README.md index 600049c..d0a9649 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ flowchart LR ## Documentation -*Documentation is coming soon™* +* [Access control (OpenID Connect)](doc/access-control.md) ## How to run diff --git a/compose.yaml b/compose.yaml index d406366..fb7ffca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: wombat POSTGRES_DB: wombat volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql boavizta: image: ghcr.io/boavizta/boaviztapi:2.4.1 @@ -45,7 +45,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - - ./app/compose/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./compose/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus grafana: diff --git a/doc/access-control.md b/doc/access-control.md new file mode 100644 index 0000000..72e53e5 --- /dev/null +++ b/doc/access-control.md @@ -0,0 +1,53 @@ +# Access Control + +Wombat can optionally restrict access to the UI and REST endpoints using OpenID Connect. It works with any compliant provider (Keycloak, Microsoft Entra ID, Okta, Auth0, Google, ...). Authentication is disabled by default and enabled by the `oidc` profile. + +## Provider + +All shared OIDC settings live in [`application-oidc.yaml`](../src/main/resources/application-oidc.yaml): protected paths, scopes, callback path, proxy headers. Enable them with the `oidc` profile and set only what is specific to your provider: + +```sh +QUARKUS_PROFILE=prod,oidc +QUARKUS_OIDC_AUTH_SERVER_URL=https://your-idp.example.com # issuer URL +QUARKUS_OIDC_CLIENT_ID= +QUARKUS_OIDC_CREDENTIALS_SECRET= +``` + +In dev mode, use `./mvnw quarkus:dev -Dquarkus.profile=dev,oidc` (see [`application-dev.yaml.dist`](../src/main/resources/application-dev.yaml.dist)). + +Any property of `application-oidc.yaml` can be overridden the usual way, e.g. `QUARKUS_OIDC_ROLES_ROLE_CLAIM_PATH`. +The profile trusts `X-Forwarded-*` headers, so redirect URIs use the external `https` URL behind a TLS-terminating proxy or ingress. If Wombat is directly reachable by clients, disable this with `QUARKUS_HTTP_PROXY_PROXY_ADDRESS_FORWARDING=false`. + +| Provider | `auth-server-url` | Notes | +|---|---|---| +| Keycloak | `https:///realms/` | Roles: `QUARKUS_OIDC_ROLES_ROLE_CLAIM_PATH=realm_access/roles` | +| Microsoft Entra ID | `https://login.microsoftonline.com//v2.0` | Roles: `roles` (app roles) or `groups` | +| Okta | `https://.okta.com/oauth2/default` | Roles: `groups` (add a groups claim to the authorization server) | +| Google | `https://accounts.google.com` | No roles and no logout endpoint: use `AUTH_ALLOWED_EMAIL_DOMAINS`, and set `QUARKUS_OIDC_AUTHENTICATION_USER_INFO_REQUIRED=true` | + +Register these URLs with your provider: +- redirect URI: `https:///auth/callback` +- post-logout redirect URI: `https:///logout/done` + +Pure OAuth2 providers such as GitHub are not OpenID Connect and are not supported. + +## Access rules + +By default every user the provider authenticates gets access. Use the `auth` rules to restrict access; when several are set, all of them must pass: + +```sh +AUTH_ROLES_ALLOWED=wombat-users # needs QUARKUS_OIDC_ROLES_ROLE_CLAIM_PATH +AUTH_ALLOWED_EMAIL_DOMAINS=example.com # comma-separated +AUTH_EMAIL_CLAIM=email # claim checked against the allowed domains +AUTH_REQUIRE_VERIFIED_EMAIL=true # requires the email_verified claim to be true +AUTH_DISPLAY_NAME_CLAIMS=email,name,preferred_username # first claim found is shown in the header +AUTH_IDP_LOGOUT=true # also ends the provider session when it exposes an end_session_endpoint +``` + +Unauthenticated browsers are redirected to the provider, and unauthenticated REST calls get `401`. Authenticated callers that fail a rule get `403`. + +`/q/health`, `/q/metrics`, `/logout` and static assets under `/public/` stay public. Other paths are protected by the `quarkus.http.auth.permission` entries in [`application-oidc.yaml`](../src/main/resources/application-oidc.yaml). + +## Logout + +`/logout` clears the Wombat session. If the provider advertises an `end_session_endpoint` (Keycloak, Entra ID, Okta, Auth0, ...), Wombat also ends the provider session and the provider sends the user back to `/logout/done`. Providers without that endpoint (Google) only get the Wombat session cleared. diff --git a/pom.xml b/pom.xml index 36bcc28..964bb65 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,25 @@ 1.2.1 + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + tech.illuin @@ -167,6 +186,19 @@ io.quarkus quarkus-smallrye-health + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-test-security-oidc + test + software.amazon.awssdk s3 @@ -248,25 +280,6 @@ - - - - ${quarkus.platform.group-id} - ${quarkus.platform.artifact-id} - ${quarkus.platform.version} - pom - import - - - org.testcontainers - testcontainers-bom - ${testcontainers.version} - pom - import - - - - @@ -422,7 +435,7 @@ https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZOIHTWEBlw.woff2 - ${project.build.outputDirectory}/META-INF/resources/vendor/fonts + ${project.build.outputDirectory}/META-INF/resources/public/fonts dmsans-latin.woff2 97e85d9e72e66a605e3defbf6749ba7811e52f584b9447e7ad969b046b045b34ffa6870460552c1f46df78dbf3011889216541bba88019eb727661ebb9d89b6f @@ -435,7 +448,7 @@ https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZ2IHTWEBlwu8Q.woff2 - ${project.build.outputDirectory}/META-INF/resources/vendor/fonts + ${project.build.outputDirectory}/META-INF/resources/public/fonts dmsans-latin-ext.woff2 8caa8310822b3328d2e394143ca8dca25a1a2bc5b0ef0ad29f14dfd8160d5a4440ca427fdfbfa58b686de28edf62e2938a09fed6282c5c2da5e07d4f4675e5ef diff --git a/src/main/java/tech/illuin/wombat/security/AccessPolicy.java b/src/main/java/tech/illuin/wombat/security/AccessPolicy.java new file mode 100644 index 0000000..2723e69 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/security/AccessPolicy.java @@ -0,0 +1,90 @@ +package tech.illuin.wombat.security; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Locale; + +@ApplicationScoped +public class AccessPolicy implements HttpSecurityPolicy +{ + static final String NAME = "wombat-access"; + private static final String EMAIL_VERIFIED_CLAIM = "email_verified"; + + private static final Logger logger = LoggerFactory.getLogger(AccessPolicy.class); + + private final AuthProperties properties; + + public AccessPolicy(AuthProperties properties) + { + this.properties = properties; + } + + @Override + public Uni checkPermission(RoutingContext request, Uni identity, AuthorizationRequestContext requestContext) + { + return identity.map(this::check); + } + + @Override + public String name() + { + return NAME; + } + + CheckResult check(SecurityIdentity identity) + { + if (identity == null || identity.isAnonymous()) + return CheckResult.DENY; + + if (!this.hasAllowedRole(identity)) + { + logger.debug("Access denied to {}: none of the roles {} granted", identity.getPrincipal().getName(), this.properties.rolesAllowed().orElseThrow()); + return CheckResult.DENY; + } + if (!this.hasAllowedEmail(identity)) + { + logger.debug("Access denied to {}: email not verified or outside {}", identity.getPrincipal().getName(), this.properties.allowedEmailDomains().orElseThrow()); + return CheckResult.DENY; + } + return CheckResult.PERMIT; + } + + private boolean hasAllowedRole(SecurityIdentity identity) + { + return this.properties.rolesAllowed() + .map(roles -> roles.stream().anyMatch(identity::hasRole)) + .orElse(true); + } + + private boolean hasAllowedEmail(SecurityIdentity identity) + { + if (this.properties.allowedEmailDomains().isEmpty()) + return true; + + if (this.properties.requireVerifiedEmail() && !IdentityClaims.isTrue(identity, EMAIL_VERIFIED_CLAIM)) + return false; + + return IdentityClaims.string(identity, this.properties.emailClaim()) + .map(email -> isInDomains(email, this.properties.allowedEmailDomains().get())) + .orElse(false); + } + + private static boolean isInDomains(String email, List domains) + { + int at = email.lastIndexOf('@'); + if (at < 0) + return false; + + String domain = email.substring(at + 1).toLowerCase(Locale.ROOT); + return domains.stream() + .map(allowed -> allowed.strip().replaceFirst("^@", "").toLowerCase(Locale.ROOT)) + .anyMatch(domain::equals); + } +} diff --git a/src/main/java/tech/illuin/wombat/security/AuthProperties.java b/src/main/java/tech/illuin/wombat/security/AuthProperties.java new file mode 100644 index 0000000..3a89ff2 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/security/AuthProperties.java @@ -0,0 +1,35 @@ +package tech.illuin.wombat.security; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +import java.util.List; +import java.util.Optional; + +/** + * Provider-agnostic access rules applied on top of the OIDC authentication (only relevant when {@code quarkus.oidc.tenant-enabled} is true). + */ +@ConfigMapping(prefix = "auth") +public interface AuthProperties +{ + /** When set, the caller must hold at least one of these roles (see {@code quarkus.oidc.roles.role-claim-path}). */ + Optional> rolesAllowed(); + + /** When set, the caller's email must belong to one of these domains (e.g. {@code example.com}). */ + Optional> allowedEmailDomains(); + + @WithDefault("email") + String emailClaim(); + + /** Rejects emails not flagged by the {@code email_verified} claim when checking {@link #allowedEmailDomains()}. */ + @WithDefault("true") + boolean requireVerifiedEmail(); + + /** Claims tried in order to display the signed-in user, falling back to the principal name. */ + @WithDefault("email,name,preferred_username") + List displayNameClaims(); + + /** Ends the identity provider session on logout when the provider advertises an {@code end_session_endpoint}. */ + @WithDefault("true") + boolean idpLogout(); +} diff --git a/src/main/java/tech/illuin/wombat/security/IdentityClaims.java b/src/main/java/tech/illuin/wombat/security/IdentityClaims.java new file mode 100644 index 0000000..0b4e0ea --- /dev/null +++ b/src/main/java/tech/illuin/wombat/security/IdentityClaims.java @@ -0,0 +1,53 @@ +package tech.illuin.wombat.security; + +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Optional; + +public final class IdentityClaims +{ + /** {@code io.quarkus.oidc.runtime.OidcUtils#USER_INFO_ATTRIBUTE} */ + private static final String USER_INFO_ATTRIBUTE = "userinfo"; + + private IdentityClaims() {} + + public static Optional string(SecurityIdentity identity, String claim) + { + return raw(identity, claim) + .map(IdentityClaims::asString) + .filter(value -> !value.isBlank()); + } + + public static boolean isTrue(SecurityIdentity identity, String claim) + { + return raw(identity, claim) + .map(value -> JsonValue.TRUE.equals(value) || Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(asString(value))) + .orElse(false); + } + + private static Optional raw(SecurityIdentity identity, String claim) + { + if (identity.getPrincipal() instanceof JsonWebToken token && token.getClaim(claim) != null) + return Optional.of(token.getClaim(claim)); + + UserInfo userInfo = identity.getAttribute(USER_INFO_ATTRIBUTE); + if (userInfo != null && userInfo.contains(claim)) + return Optional.ofNullable(userInfo.get(claim)); + + return Optional.empty(); + } + + private static String asString(Object value) + { + return switch (value) + { + case String string -> string; + case JsonString json -> json.getString(); + default -> value.toString(); + }; + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/CurrentUser.java b/src/main/java/tech/illuin/wombat/ui/CurrentUser.java new file mode 100644 index 0000000..89d7710 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/CurrentUser.java @@ -0,0 +1,32 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.security.identity.SecurityIdentity; +import tech.illuin.wombat.security.IdentityClaims; + +import java.util.List; +import java.util.Optional; + +public record CurrentUser( + boolean authenticated, + String name +) +{ + static final CurrentUser ANONYMOUS = new CurrentUser(false, null); + + static CurrentUser from(SecurityIdentity identity, List displayNameClaims) + { + if (identity == null || identity.isAnonymous()) + return ANONYMOUS; + + return new CurrentUser(true, name(identity, displayNameClaims)); + } + + private static String name(SecurityIdentity identity, List displayNameClaims) + { + return displayNameClaims.stream() + .map(claim -> IdentityClaims.string(identity, claim)) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> identity.getPrincipal() == null ? null : identity.getPrincipal().getName()); + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/RouteConfig.java b/src/main/java/tech/illuin/wombat/ui/RouteConfig.java new file mode 100644 index 0000000..dcb04c9 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/RouteConfig.java @@ -0,0 +1,41 @@ +package tech.illuin.wombat.ui; + +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.Router; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class RouteConfig +{ + private static final String DASHBOARD_PATH = "/"; + + private static final Logger logger = LoggerFactory.getLogger(RouteConfig.class); + + public void registerDashboardFallback(@Observes Router router) + { + router.route().order(Integer.MAX_VALUE).handler(context -> { + HttpMethod method = context.request().method(); + boolean readOnly = HttpMethod.GET.equals(method) || HttpMethod.HEAD.equals(method); + boolean oidcCallback = context.request().getParam("code") != null && context.request().getParam("state") != null; + + if (!readOnly || oidcCallback) + { + context.next(); + return; + } + + String query = context.request().query(); + String target = query == null || query.isBlank() ? DASHBOARD_PATH : DASHBOARD_PATH + "?" + query; + + logger.debug("No route for {}, redirecting to {}", context.request().uri(), target); + context.response() + .setStatusCode(302) + .putHeader(HttpHeaders.LOCATION, target) + .end(); + }); + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/SessionController.java b/src/main/java/tech/illuin/wombat/ui/SessionController.java new file mode 100644 index 0000000..ec0f94a --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/SessionController.java @@ -0,0 +1,87 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OidcConfigurationMetadata; +import io.quarkus.oidc.OidcSession; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import tech.illuin.wombat.security.AuthProperties; + +import java.net.URI; +import java.util.Optional; + +@Path("logout") +public class SessionController +{ + /** {@code io.quarkus.oidc.runtime.OidcUtils#CONFIG_METADATA_ATTRIBUTE} */ + private static final String CONFIG_METADATA_ATTRIBUTE = "configuration-metadata"; + private static final String POST_LOGOUT_PATH = "logout/done"; + + private final OidcSession oidcSession; + private final SecurityIdentity identity; + private final AuthProperties properties; + private final Optional clientId; + + public SessionController( + OidcSession oidcSession, + SecurityIdentity identity, + AuthProperties properties, + @ConfigProperty(name = "quarkus.oidc.client-id") Optional clientId + ) { + this.oidcSession = oidcSession; + this.identity = identity; + this.properties = properties; + this.clientId = clientId; + } + + /** + * Clears the local session, then ends the identity provider session (RP-initiated logout) when the provider supports it. + * Providers without an {@code end_session_endpoint} (e.g. Google) only get the local session cleared. + */ + @GET + @Produces(MediaType.TEXT_HTML) + public Uni logout(@Context UriInfo uriInfo) + { + IdTokenCredential idToken = this.identity.getCredential(IdTokenCredential.class); + if (idToken == null) + return Uni.createFrom().item(Response.ok(Templates.loggedOut(false)).build()); + + Optional endSession = this.properties.idpLogout() ? this.endSessionUri(idToken, uriInfo) : Optional.empty(); + return this.oidcSession.logout().map(ignored -> endSession + .map(uri -> Response.seeOther(uri).build()) + .orElseGet(() -> Response.ok(Templates.loggedOut(false)).build()) + ); + } + + /** Landing page the identity provider redirects to once its session is ended. */ + @GET + @Path("done") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance loggedOut() + { + return Templates.loggedOut(true); + } + + private Optional endSessionUri(IdTokenCredential idToken, UriInfo uriInfo) + { + OidcConfigurationMetadata metadata = this.identity.getAttribute(CONFIG_METADATA_ATTRIBUTE); + if (metadata == null || metadata.getEndSessionUri() == null) + return Optional.empty(); + + UriBuilder builder = UriBuilder.fromUri(metadata.getEndSessionUri()) + .queryParam("id_token_hint", idToken.getToken()) + .queryParam("post_logout_redirect_uri", uriInfo.getBaseUriBuilder().path(POST_LOGOUT_PATH).build()); + this.clientId.ifPresent(id -> builder.queryParam("client_id", id)); + return Optional.of(builder.build()); + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/Templates.java b/src/main/java/tech/illuin/wombat/ui/Templates.java index 55541de..20c792e 100644 --- a/src/main/java/tech/illuin/wombat/ui/Templates.java +++ b/src/main/java/tech/illuin/wombat/ui/Templates.java @@ -22,6 +22,8 @@ public static native TemplateInstance impact( public static native TemplateInstance impactError(TimeRange timeRange, MaxSpan maxSpan); + public static native TemplateInstance loggedOut(boolean idpSessionEnded); + public record MaxSpan(long millis, String label) {} public record AssetSelection(List all, List selectedIds) {} diff --git a/src/main/java/tech/illuin/wombat/ui/UIConfig.java b/src/main/java/tech/illuin/wombat/ui/UIConfig.java new file mode 100644 index 0000000..fecff3c --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/UIConfig.java @@ -0,0 +1,18 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Named; +import tech.illuin.wombat.security.AuthProperties; + +@ApplicationScoped +public class UIConfig +{ + @Named("currentUser") + @Dependent + public CurrentUser provideCurrentUser(SecurityIdentity identity, AuthProperties authProperties) + { + return CurrentUser.from(identity, authProperties.displayNameClaims()); + } +} diff --git a/src/main/resources/META-INF/resources/vendor/dmsans.css b/src/main/resources/META-INF/resources/public/dmsans.css similarity index 86% rename from src/main/resources/META-INF/resources/vendor/dmsans.css rename to src/main/resources/META-INF/resources/public/dmsans.css index 083a004..b8f4422 100644 --- a/src/main/resources/META-INF/resources/vendor/dmsans.css +++ b/src/main/resources/META-INF/resources/public/dmsans.css @@ -7,7 +7,7 @@ font-style: normal; font-weight: 400 700; font-display: swap; - src: url(/vendor/fonts/dmsans-latin-ext.woff2) format('woff2'); + src: url(/public/fonts/dmsans-latin-ext.woff2) format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @@ -16,6 +16,6 @@ font-style: normal; font-weight: 400 700; font-display: swap; - src: url(/vendor/fonts/dmsans-latin.woff2) format('woff2'); + src: url(/public/fonts/dmsans-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } diff --git a/src/main/resources/META-INF/resources/easepick-theme.css b/src/main/resources/META-INF/resources/public/easepick-theme.css similarity index 100% rename from src/main/resources/META-INF/resources/easepick-theme.css rename to src/main/resources/META-INF/resources/public/easepick-theme.css diff --git a/src/main/resources/META-INF/resources/wombat-large-mask.png b/src/main/resources/META-INF/resources/public/icons/wombat-large-mask.png similarity index 100% rename from src/main/resources/META-INF/resources/wombat-large-mask.png rename to src/main/resources/META-INF/resources/public/icons/wombat-large-mask.png diff --git a/src/main/resources/META-INF/resources/wombat-medium-mask.png b/src/main/resources/META-INF/resources/public/icons/wombat-medium-mask.png similarity index 100% rename from src/main/resources/META-INF/resources/wombat-medium-mask.png rename to src/main/resources/META-INF/resources/public/icons/wombat-medium-mask.png diff --git a/src/main/resources/META-INF/resources/impact.css b/src/main/resources/META-INF/resources/public/impact.css similarity index 94% rename from src/main/resources/META-INF/resources/impact.css rename to src/main/resources/META-INF/resources/public/impact.css index d7c18ad..d2773f1 100644 --- a/src/main/resources/META-INF/resources/impact.css +++ b/src/main/resources/META-INF/resources/public/impact.css @@ -56,6 +56,13 @@ body { background: var(--bg); color: var(--text); font-family: var(--sans); font .theme-toggle:hover { color: var(--accent-green); border-color: var(--accent-green); } .theme-toggle:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(22,163,74,0.18); } .header-meta + .theme-toggle { margin-left: 12px; } +.header-user { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; max-width: 320px; font-size: 12px; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); padding: 4px 6px 4px 12px; border-radius: 20px; } +.header-meta + .header-user { margin-left: 12px; } +.header-user + .theme-toggle { margin-left: 12px; } +.header-user-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-logout { flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); text-decoration: none; transition: color 0.15s, background 0.15s; } +.header-logout:hover { color: var(--accent-green); background: var(--surface); } +.header-logout:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(22,163,74,0.18); } .theme-toggle .icon-sun { display: none; } .theme-toggle .icon-moon { display: block; } [data-theme="dark"] .theme-toggle .icon-sun { display: block; } @@ -182,6 +189,8 @@ table { min-width: 600px; } @media (max-width: 900px) { .overview-row, .metrics-row { grid-template-columns: 1fr; } } @media (max-width: 640px) { .header-meta { display: none; } + .header-user { padding: 4px 6px; } + .header-user-name { display: none; } .main { padding: 16px; } .filter-bar { padding: 12px 16px; gap: 12px; } .filter-group { width: 100%; } @@ -268,6 +277,12 @@ tbody td.metric.bold { font-weight: 700; } .chart-pill.yellow.active { background: var(--accent-yellow-soft); border-color: var(--accent-yellow); color: var(--accent-yellow); } .chart-note { margin-top: 10px; font-size: 11px; color: var(--text-dim); line-height: 1.5; } +/* ── Notice card (sign-out confirmation) ── */ +.notice-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 36px 40px; display: flex; flex-direction: column; gap: 12px; max-width: 560px; box-shadow: var(--shadow); } +.notice-title { font-size: 18px; font-weight: 700; color: var(--text); } +.notice-body { font-size: 13px; color: var(--text-muted); line-height: 1.6; } +.btn-link { align-self: flex-start; display: inline-block; text-decoration: none; } + /* ── Date range picker ── */ .date-range-group { flex-wrap: wrap; align-items: flex-start; } .date-range-picker { background: var(--surface2); border: 1.5px solid var(--border); color: var(--text); font-family: var(--sans); font-size: 13px; padding: 8px 12px; border-radius: var(--radius-sm); outline: none; cursor: pointer; min-width: 320px; transition: border-color 0.15s, box-shadow 0.15s; } diff --git a/src/main/resources/META-INF/resources/impact.js b/src/main/resources/META-INF/resources/public/impact.js similarity index 100% rename from src/main/resources/META-INF/resources/impact.js rename to src/main/resources/META-INF/resources/public/impact.js diff --git a/src/main/resources/META-INF/resources/impactError.js b/src/main/resources/META-INF/resources/public/impactError.js similarity index 100% rename from src/main/resources/META-INF/resources/impactError.js rename to src/main/resources/META-INF/resources/public/impactError.js diff --git a/src/main/resources/META-INF/resources/impactUtils.js b/src/main/resources/META-INF/resources/public/impactUtils.js similarity index 100% rename from src/main/resources/META-INF/resources/impactUtils.js rename to src/main/resources/META-INF/resources/public/impactUtils.js diff --git a/src/main/resources/application-dev.yaml.dist b/src/main/resources/application-dev.yaml.dist index a956258..ba29818 100644 --- a/src/main/resources/application-dev.yaml.dist +++ b/src/main/resources/application-dev.yaml.dist @@ -17,6 +17,22 @@ connector: monitor: environments-file: monitored/monitored-environments-dev.yaml +"%oidc": + quarkus: + oidc: + auth-server-url: https://your-idp.example.com + client-id: + credentials: + secret: + http: + auth: + permission: + dev-ui: + policy: permit + paths: + - /q/dev/* + - /q/dev-ui/* + # Local S3 Backup setup (e.g. SeaweedFS service) backup: enabled: true diff --git a/src/main/resources/application-oidc.yaml b/src/main/resources/application-oidc.yaml new file mode 100644 index 0000000..5cccbbc --- /dev/null +++ b/src/main/resources/application-oidc.yaml @@ -0,0 +1,38 @@ +quarkus: + oidc: + tenant-enabled: true + application-type: hybrid + authentication: + scopes: + - email + - profile + redirect-path: /auth/callback + restore-path-after-redirect: true + http: + proxy: + proxy-address-forwarding: true + enable-forwarded-host: true + auth: + permission: + public: + policy: permit + paths: + - /q/metrics + - /q/health + - /q/health/* + - /logout + - /logout/* + - /public/* + authenticated: + policy: wombat-access # authenticated + optional auth.* rules + paths: + - /* + +## Access rules (all optional) +#auth: +# roles-allowed: [wombat-users] +# allowed-email-domains: [example.com] +# email-claim: email +# require-verified-email: true +# display-name-claims: [email, name, preferred_username] +# idp-logout: true diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0f4b471..1cf8146 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -42,6 +42,11 @@ quarkus: kubernetes-client: devservices: enabled: false + oidc: + tenant-enabled: false # enabled by the "oidc" profile, see application-oidc.yaml + keycloak: + devservices: + enabled: false micrometer: binder: netty: diff --git a/src/main/resources/templates/impact.html b/src/main/resources/templates/impact.html index d9eebc6..3ac6b31 100644 --- a/src/main/resources/templates/impact.html +++ b/src/main/resources/templates/impact.html @@ -549,6 +549,6 @@ {/if} {#include impactScript.html /} - + {/include} diff --git a/src/main/resources/templates/impactError.html b/src/main/resources/templates/impactError.html index 791cddc..eb135ae 100644 --- a/src/main/resources/templates/impactError.html +++ b/src/main/resources/templates/impactError.html @@ -1,8 +1,8 @@ {#include impactLayout.html} {#headScripts} - - + + {/headScripts} diff --git a/src/main/resources/templates/impactLayout.html b/src/main/resources/templates/impactLayout.html index 10966b8..eccb4cc 100644 --- a/src/main/resources/templates/impactLayout.html +++ b/src/main/resources/templates/impactLayout.html @@ -4,18 +4,30 @@ Impact Report - - - - + + + + {#insert headScripts /}
- +

Wombat — Environmental Impact

{#insert headerExtra /} + {#if inject:currentUser.authenticated} +
+ {inject:currentUser.name} + + + + + + + +
+ {/if}