Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ flowchart LR

## Documentation

*Documentation is coming soon™*
* [Access control (OpenID Connect)](doc/access-control.md)

## How to run

Expand Down
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
53 changes: 53 additions & 0 deletions doc/access-control.md
Original file line number Diff line number Diff line change
@@ -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=<client-id>
QUARKUS_OIDC_CREDENTIALS_SECRET=<client-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://<host>/realms/<realm>` | Roles: `QUARKUS_OIDC_ROLES_ROLE_CLAIM_PATH=realm_access/roles` |
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant-id>/v2.0` | Roles: `roles` (app roles) or `groups` |
| Okta | `https://<org>.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://<wombat-host>/auth/callback`
- post-logout redirect URI: `https://<wombat-host>/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.
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>${testcontainers.version}</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
90 changes: 90 additions & 0 deletions src/main/java/tech/illuin/wombat/security/AccessPolicy.java
Original file line number Diff line number Diff line change
@@ -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<CheckResult> checkPermission(RoutingContext request, Uni<SecurityIdentity> 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<String> 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);
}
}
35 changes: 35 additions & 0 deletions src/main/java/tech/illuin/wombat/security/AuthProperties.java
Original file line number Diff line number Diff line change
@@ -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<List<String>> rolesAllowed();

/** When set, the caller's email must belong to one of these domains (e.g. {@code example.com}). */
Optional<List<String>> 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<String> displayNameClaims();

/** Ends the identity provider session on logout when the provider advertises an {@code end_session_endpoint}. */
@WithDefault("true")
boolean idpLogout();
}
53 changes: 53 additions & 0 deletions src/main/java/tech/illuin/wombat/security/IdentityClaims.java
Original file line number Diff line number Diff line change
@@ -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> 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<Object> 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();
};
}
}
32 changes: 32 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,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<String> displayNameClaims)
{
if (identity == null || identity.isAnonymous())
return ANONYMOUS;

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

private static String name(SecurityIdentity identity, List<String> displayNameClaims)
{
return displayNameClaims.stream()
.map(claim -> IdentityClaims.string(identity, claim))
.flatMap(Optional::stream)
.findFirst()
.orElseGet(() -> identity.getPrincipal() == null ? null : identity.getPrincipal().getName());
}
}
Loading
Loading