diff --git a/.gitignore b/.gitignore
index be318a5..645de42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
.bundle/*
./local/*
.DS_Store
+.env
/data/*
!/data/gingr
@@ -14,7 +15,9 @@
/data/gingr/processed/*
/data/gingr/failed/*
/data/gingr/processing/*
-
+!/data/geoserver/
+/data/geoserver/*
+!/data/geoserver/keycloak/
/solr/geodata-test/data
/solr/geodata-test/data
diff --git a/README.md b/README.md
index cc4b415..1c43bee 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,16 @@ Command-line tool for ingesting GeoData (Solr, GeoServer, and file servers).
The app is Dockerized with an image designed to just run the `gingr` executable:
```sh
+# set your env and then make any changes necessary.
+cp .env.example .env
+
# Build the image
docker compose build
+# The geoserver-config will need to run every time unless you mount the geoserver_data volume
+# and comment out the geoserver-config service.
+docker compose up -d
+
# Use `docker compose watch` to automatically rebuild on changes. This takes over the terminal
# you run it in, so you'll need to either background it (`screen` works great) or open a different window.
docker compose watch
diff --git a/bin/geoserver-oidc-config.sh b/bin/geoserver-oidc-config.sh
new file mode 100644
index 0000000..7e6dfcd
--- /dev/null
+++ b/bin/geoserver-oidc-config.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env sh
+set -eu
+
+GEOSERVER_AUTH="${GEOSERVER_AUTH:-admin:geoserver}"
+
+# This script assumes both geoserver and geoserver-secure are healthy
+# and configured exactly the same.
+for GEOSERVER_HOST in geoserver geoserver-secure; do
+ GEOSERVER_REST="http://${GEOSERVER_HOST}:8080/geoserver/rest"
+
+ echo "Creating keycloak OIDC auth filter on ${GEOSERVER_HOST}..."
+ HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ --request POST \
+ --url "${GEOSERVER_REST}/security/authfilters" \
+ --user "${GEOSERVER_AUTH}" \
+ --header 'Content-Type: application/xml; charset=utf-8' \
+ --data "
+ keycloak
+ org.geoserver.security.oauth2.login.GeoServerOAuth2LoginAuthenticationFilter
+ IdToken
+ http://localhost:8080/geoserver/
+ false
+ email
+ http://localhost:8080/geoserver/web/login/oauth2/code/google
+ false
+ id
+ http://localhost:8080/geoserver/web/login/oauth2/code/gitHub
+ false
+ sub
+ http://localhost:8080/geoserver/web/login/oauth2/code/microsoft
+ openid profile email
+ true
+ ${OIDC_CLIENT_ID}
+ ${OIDC_CLIENT_SECRET}
+ email
+ http://localhost:8080/geoserver/web/login/oauth2/code/oidc
+ openid berkeley_edu_groups email profile
+ http://keycloak:8180/realms/berkeley-local/.well-known/openid-configuration
+ http://keycloak:8180/realms/berkeley-local/protocol/openid-connect/token
+ http://keycloak.localhost:8180/realms/berkeley-local/protocol/openid-connect/auth
+ http://keycloak:8180/realms/berkeley-local/protocol/openid-connect/userinfo
+ http://keycloak:8180/realms/berkeley-local/protocol/openid-connect/certs
+ http://keycloak.localhost:8180/realms/berkeley-local/protocol/openid-connect/logout
+ false
+ false
+ true
+ false
+ false
+ false
+ preferred_username
+ http://localhost:8080/geoserver/web/
+ true
+ false
+ false
+ testadmin=ADMIN
+ false
+ ")
+
+ case "$HTTP_STATUS" in
+ 200|201) echo "Auth filter created." ;;
+ *) echo "ERROR: Unexpected status creating auth filter on ${GEOSERVER_HOST}: $HTTP_STATUS"; exit 1 ;;
+ esac
+
+ echo "Updating web filter chain on ${GEOSERVER_HOST}..."
+ FILTER_CHAIN_UPDATE_HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ --request PUT \
+ --url "${GEOSERVER_REST}/security/filterchain/web" \
+ --user "${GEOSERVER_AUTH}" \
+ --header 'Content-Type: application/xml; charset=utf-8' \
+ --data '
+ rememberme
+ form
+ keycloak
+ anonymous
+ ')
+
+ case "$FILTER_CHAIN_UPDATE_HTTP_STATUS" in
+ 200) echo "Web filter chain updated." ;;
+ *) echo "ERROR: Unexpected status updating web filter chain: $FILTER_CHAIN_UPDATE_HTTP_STATUS"; exit 1 ;;
+ esac
+
+ echo "Done with ${GEOSERVER_HOST}."
+done
diff --git a/data/geoserver/keycloak/config/berkeley-local.yaml b/data/geoserver/keycloak/config/berkeley-local.yaml
new file mode 100644
index 0000000..ad00441
--- /dev/null
+++ b/data/geoserver/keycloak/config/berkeley-local.yaml
@@ -0,0 +1,102 @@
+# keycloak-config-cli realm configuration for local GeoServer development.
+# It provides a local Keycloak realm, GeoServer client, and Berkeley-style
+# group claims for predictable OIDC testing.
+#
+# Group names mirror the production LDAP DN format so GeoServer can test the same
+# role-mapping shape used against the real IdP.
+#
+# Secrets are injected at import time via substitution by keycloak-config-cli.
+# Set OIDC_CLIENT_SECRET in your .env file — never commit a real secret here.
+
+realm: berkeley-local
+enabled: true
+displayName: Berkeley Local
+
+clientScopes:
+ - name: berkeley_edu_groups
+ description: Berkeley group memberships
+ protocol: openid-connect
+ attributes:
+ include.in.token.scope: "true"
+ display.on.consent.screen: "false"
+ protocolMappers:
+ - name: groups
+ protocol: openid-connect
+ protocolMapper: oidc-group-membership-mapper
+ consentRequired: false
+ config:
+ # Match Calnet's behavior by not prepending groups with "/"
+ full.path: "false"
+ id.token.claim: "true"
+ access.token.claim: "true"
+ introspection.token.claim: "true"
+ userinfo.token.claim: "true"
+ claim.name: groups
+ jsonType.label: String
+
+clients:
+ - clientId: "$(OIDC_CLIENT_ID)"
+ name: "$(OIDC_CLIENT_ID)"
+ enabled: true
+ rootUrl: "http://localhost:8080"
+ adminUrl: "http://localhost:8080"
+ redirectUris:
+ - "http://localhost:8080/*"
+ webOrigins:
+ - "http://localhost:8080"
+ publicClient: false
+ secret: "$(OIDC_CLIENT_SECRET)"
+ standardFlowEnabled: true
+ directAccessGrantsEnabled: true
+ serviceAccountsEnabled: true
+ frontchannelLogout: true
+ attributes:
+ backchannel.logout.session.required: "true"
+ frontchannel.logout.session.required: "true"
+ defaultClientScopes:
+ - profile
+ - email
+ - berkeley_edu_groups
+
+groups:
+ - name: "$(OIDC_ADMIN_GROUP)"
+ - name: "$(OIDC_USER_GROUP)"
+ - name: "cn=edu:berkeley:org:libr:unrelated:group,ou=campus groups,dc=berkeley,dc=edu"
+
+users:
+ - username: testadmin
+ enabled: true
+ email: testadmin@example.edu
+ firstName: Test
+ lastName: Admin
+ credentials:
+ - type: password
+ value: testadmin
+ temporary: false
+ groups:
+ - "$(OIDC_ADMIN_GROUP)"
+ - "$(OIDC_USER_GROUP)"
+
+ - username: testuser
+ enabled: true
+ email: testuser@example.edu
+ firstName: Test
+ lastName: User
+ credentials:
+ - type: password
+ value: testuser
+ temporary: false
+ groups:
+ - "$(OIDC_USER_GROUP)"
+
+ - username: testpublic
+ enabled: true
+ email: testpublic@example.edu
+ firstName: Test
+ lastName: Public
+ credentials:
+ - type: password
+ value: testpublic
+ temporary: false
+ groups:
+ - cn=edu:berkeley:org:libr:unrelated:group,ou=campus groups,dc=berkeley,dc=edu
diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
index 928ef09..ccf0d06 100644
--- a/docker-compose.ci.yml
+++ b/docker-compose.ci.yml
@@ -15,5 +15,13 @@ services:
geoserver-secure: !reset
+ geoserver-config: !reset
+
+ postgres: !reset
+
+ keycloak: !reset
+
+ keycloak-config: !reset
+
volumes:
artifacts:
diff --git a/docker-compose.yml b/docker-compose.yml
index 4f0e4dc..772b58f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,6 +11,8 @@ services:
condition: service_healthy
geoserver-secure:
condition: service_healthy
+ keycloak-config:
+ condition: service_completed_successfully
command:
- tail
- -f
@@ -25,7 +27,7 @@ services:
SPATIAL_URL: http://spatial
restart: no
volumes:
- - ./:/opt/app:delegated
+ - ./:/opt/app
solr:
command: >
@@ -51,27 +53,135 @@ services:
- ./data/spatial:/usr/local/apache2/htdocs/:ro
geoserver:
- image: ghcr.io/berkeleylibrary/geoserver:latest
+ image: docker.osgeo.org/geoserver:2.28-latest
ports:
- 8080:8080
+ environment: &geoserver-environment
+ ROOT_WEBAPP_REDIRECT: "true"
+ SKIP_DEMO_DATA: "true"
+ INSTALL_EXTENSIONS: "true"
+ COMMUNITY_EXTENSIONS: "sec-oidc"
+ PROXY_BASE_URL:
+ depends_on:
+ keycloak-config:
+ condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/geoserver/web/"]
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
+ extra_hosts:
+ - keycloak.localhost:host-gateway
volumes:
- - ./data/geoserver/public:/srv/geofiles:delegated
+ # Uncomment and comment out the geoserver-config service to persist geoserver between runs.
+ # - ./data/geoserver/geoserver_data:/opt/geoserver_data
+ - ./data/geoserver/public:/srv/geofiles
geoserver-secure:
- image: ghcr.io/berkeleylibrary/geoserver:latest
+ image: docker.osgeo.org/geoserver:2.28-latest
ports:
- 8081:8080
+ environment: *geoserver-environment
+ depends_on:
+ keycloak-config:
+ condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/geoserver/web/"]
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
+ extra_hosts:
+ - keycloak.localhost:host-gateway
+ volumes:
+ - ./data/geoserver/UCB:/srv/geofiles
+
+ geoserver-config:
+ image: curlimages/curl:latest
+ depends_on:
+ geoserver:
+ condition: service_healthy
+ geoserver-secure:
+ condition: service_healthy
+ entrypoint: ["/bin/sh"]
+ command: ["/scripts/geoserver-oidc-config.sh"]
+ environment:
+ OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
+ OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
+ restart: on-failure:3
+ volumes:
+ - ./bin/geoserver-oidc-config.sh:/scripts/geoserver-oidc-config.sh:ro
+
+ postgres:
+ image: postgres:18-alpine
+ environment:
+ POSTGRES_DB: keycloak
+ POSTGRES_USER: keycloak
+ POSTGRES_PASSWORD: keycloak
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
volumes:
- - ./data/geoserver/UCB:/srv/geofiles:delegated
+ - postgres_data:/var/lib/postgresql
+
+ keycloak:
+ command: start-dev
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
+ KC_DB_USERNAME: keycloak
+ KC_DB_PASSWORD: keycloak
+ KC_BOOTSTRAP_ADMIN_USERNAME: admin
+ KC_BOOTSTRAP_ADMIN_PASSWORD: admin
+ KC_HOSTNAME: "http://keycloak.localhost:8180"
+ KC_HOSTNAME_STRICT: "false"
+ KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
+ KC_HTTP_ENABLED: "true"
+ KC_HTTP_PORT: "8180"
+ KC_HEALTH_ENABLED: "true"
+ KC_LOG_LEVEL: ${KC_LOG_LEVEL:-INFO}
+ healthcheck:
+ # curl/wget are not present in the UBI9-based Keycloak image; use /dev/tcp.
+ # KC_HEALTH_ENABLED=true exposes /health/ready on the management port (9000).
+ test:
+ [
+ "CMD-SHELL",
+ "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/ready HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q 'status.*UP' <&3",
+ ]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 20s
+ image: quay.io/keycloak/keycloak:26.1
+ ports:
+ - 127.0.0.1:8180:8180
+ restart: always
+
+ keycloak-config:
+ depends_on:
+ keycloak:
+ condition: service_healthy
+ environment:
+ KEYCLOAK_URL: http://keycloak:8180
+ KEYCLOAK_USER: admin
+ KEYCLOAK_PASSWORD: admin
+ KEYCLOAK_AVAILABILITYCHECK_ENABLED: "true"
+ IMPORT_FILES_LOCATIONS: /config/*
+ IMPORT_VAR_SUBSTITUTION_ENABLED: "true"
+ OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
+ OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
+ OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP}
+ OIDC_USER_GROUP: ${OIDC_USER_GROUP}
+ image: adorsys/keycloak-config-cli:latest-26.1.0
+ restart: on-failure:3
+ volumes:
+ - ./data/geoserver/keycloak/config:/config
+
+volumes:
+ postgres_data:
diff --git a/env.example b/env.example
new file mode 100644
index 0000000..9a20406
--- /dev/null
+++ b/env.example
@@ -0,0 +1,6 @@
+OIDC_CLIENT_ID=geoserver-local
+OIDC_CLIENT_SECRET=change-me
+OIDC_ADMIN_GROUP="cn=edu:berkeley:org:libr:geoserver:admins,ou=campus groups,dc=berkeley,dc=edu"
+OIDC_NAME="keycloak"
+OIDC_USER_GROUP="cn=edu:berkeley:org:libr:geoserver:users,ou=campus groups,dc=berkeley,dc=edu"
+OIDC_WELL_KNOWN="http://keycloak:8180/realms/berkeley-local/.well-known/openid-configuration"