Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,52 @@ To package it without running tests, append `-DskipTests`:
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) ; see the [LICENSE](LICENSE) file for details.

Copyright (c) 2026 Illuin Technology.

## Access Control

Wombat can optionally restrict access to the UI and REST endpoints using OpenID Connect. Authentication is disabled by default (`quarkus.oidc.tenant-enabled: false`).

To enable it, configure your OIDC provider in `application.yaml` (or via environment variables):

```yaml
quarkus:
oidc:
tenant-enabled: true
auth-server-url: https://your-idp.example.com/realms/your-realm
# Or use a pre-configured Quarkus provider shortcut (e.g. google, github, microsoft) instead of auth-server-url:
# provider: google
application-type: hybrid
client-id: <client-id>
credentials:
secret: <client-secret>
authentication:
redirect-path: /auth/callback
restore-path-after-redirect: true
http:
auth:
permission:
public:
enabled: ${quarkus.oidc.tenant-enabled}
policy: permit
paths:
- /q/metrics
- /q/health
- /q/health/*
- /logout
- /public/*
authenticated:
enabled: ${quarkus.oidc.tenant-enabled}
policy: authenticated
paths:
- /*
```

When running behind a reverse proxy or ingress terminating TLS, enable forwarded headers so the OIDC redirect URI is resolved with `https`:

```yaml
quarkus:
http:
proxy:
proxy-address-forwarding: true
enable-forwarded-host: true
```
4 changes: 2 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 34 additions & 21 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@
<easepick.version>1.2.1</easepick.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>tech.illuin</groupId>
Expand Down Expand Up @@ -167,6 +186,19 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-oidc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
Expand Down Expand Up @@ -248,25 +280,6 @@
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
Expand Down Expand Up @@ -422,7 +435,7 @@
</goals>
<configuration>
<uri>https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZOIHTWEBlw.woff2</uri>
<outputDirectory>${project.build.outputDirectory}/META-INF/resources/vendor/fonts</outputDirectory>
<outputDirectory>${project.build.outputDirectory}/META-INF/resources/public/fonts</outputDirectory>
<outputFileName>dmsans-latin.woff2</outputFileName>
<sha512>97e85d9e72e66a605e3defbf6749ba7811e52f584b9447e7ad969b046b045b34ffa6870460552c1f46df78dbf3011889216541bba88019eb727661ebb9d89b6f</sha512>
</configuration>
Expand All @@ -435,7 +448,7 @@
</goals>
<configuration>
<uri>https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZ2IHTWEBlwu8Q.woff2</uri>
<outputDirectory>${project.build.outputDirectory}/META-INF/resources/vendor/fonts</outputDirectory>
<outputDirectory>${project.build.outputDirectory}/META-INF/resources/public/fonts</outputDirectory>
<outputFileName>dmsans-latin-ext.woff2</outputFileName>
<sha512>8caa8310822b3328d2e394143ca8dca25a1a2bc5b0ef0ad29f14dfd8160d5a4440ca427fdfbfa58b686de28edf62e2938a09fed6282c5c2da5e07d4f4675e5ef</sha512>
</configuration>
Expand Down
56 changes: 56 additions & 0 deletions src/main/java/tech/illuin/wombat/ui/CurrentUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package tech.illuin.wombat.ui;

import io.quarkus.oidc.UserInfo;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.json.JsonString;
import org.eclipse.microprofile.jwt.JsonWebToken;

/**
* Represents the currently signed-in user for Qute templates (exposed as {@code currentUser} in {@link UIConfig}).
*/
public record CurrentUser(
boolean authenticated,
String name
)
{
/** {@code io.quarkus.oidc.runtime.OidcUtils#USER_INFO_ATTRIBUTE} */
private static final String USER_INFO_ATTRIBUTE = "userinfo";
private static final String EMAIL_CLAIM = "email";

static final CurrentUser ANONYMOUS = new CurrentUser(false, null);

static CurrentUser from(SecurityIdentity identity)
{
if (identity == null || identity.isAnonymous())
return ANONYMOUS;

return new CurrentUser(true, name(identity));
}

private static String name(SecurityIdentity identity)
{
if (identity.getPrincipal() instanceof JsonWebToken token)
{
String claim = asString(token.getClaim(EMAIL_CLAIM));
if (claim != null && !claim.isBlank())
return claim;
}

UserInfo userInfo = identity.getAttribute(USER_INFO_ATTRIBUTE);
if (userInfo != null && userInfo.getEmail() != null && !userInfo.getEmail().isBlank())
return userInfo.getEmail();

return identity.getPrincipal() == null ? null : identity.getPrincipal().getName();
}

private static String asString(Object value)
{
return switch (value)
{
case null -> null;
case String string -> string;
case JsonString json -> json.getString();
default -> value.toString();
};
}
}
41 changes: 41 additions & 0 deletions src/main/java/tech/illuin/wombat/ui/RouteConfig.java
Original file line number Diff line number Diff line change
@@ -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();
});
}
}
34 changes: 34 additions & 0 deletions src/main/java/tech/illuin/wombat/ui/SessionController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package tech.illuin.wombat.ui;

import io.quarkus.oidc.IdTokenCredential;
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.MediaType;

@Path("logout")
public class SessionController
{
private final OidcSession oidcSession;
private final SecurityIdentity identity;

public SessionController(OidcSession oidcSession, SecurityIdentity identity)
{
this.oidcSession = oidcSession;
this.identity = identity;
}

@GET
@Produces(MediaType.TEXT_HTML)
public Uni<TemplateInstance> logout()
{
if (this.identity.getCredential(IdTokenCredential.class) == null)
return Uni.createFrom().item(Templates.loggedOut());

return this.oidcSession.logout().replaceWith(() -> Templates.loggedOut());
}
}
2 changes: 2 additions & 0 deletions src/main/java/tech/illuin/wombat/ui/Templates.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public static native TemplateInstance impact(

public static native TemplateInstance impactError(TimeRange timeRange, MaxSpan maxSpan);

public static native TemplateInstance loggedOut();

public record MaxSpan(long millis, String label) {}

public record AssetSelection(List<Asset> all, List<String> selectedIds) {}
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/tech/illuin/wombat/ui/UIConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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;

@ApplicationScoped
public class UIConfig
{
@Named("currentUser")
@Dependent
public CurrentUser provideCurrentUser(SecurityIdentity identity)
{
return CurrentUser.from(identity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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%; }
Expand Down Expand Up @@ -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; }
Expand Down
Loading