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"