feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
+31
View File
@@ -0,0 +1,31 @@
# Keycloak realm artifact
`realms/ca-skeleton-realm.json` is imported by the `keycloak` service in `docker-compose.infra.yml`
and is the same realm every GraphQL qualification lane authenticates against.
## The client secret is a reference, never a value
The confidential client `ca-skeleton-api` carries `"secret": "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}"`.
`entrypoint.sh` reads the value from the Compose secret mounted at
`/run/secrets/keycloak-graphql-smoke-client-secret`, exports it, and execs `kc.sh start-dev
--import-realm`, so the value never reaches Git, a rendered Compose config, a command line, or an
evidence file. A realm file with a working credential in it is a credential in the repository, and
"it is only for smoke tests" is not something a scanner or a fork can tell.
## No comment keys in the realm JSON
Keycloak deserializes this file into `RealmRepresentation` with unknown fields **rejected**, not
ignored. A `"_comment"` key here fails the whole import with `Unrecognized field "_comment"`, the
container exits 1, and the lane fails on Keycloak rather than on anything it was testing. That is
why this rationale lives in Markdown next to the artifact instead of inside it.
## What the realm grants
- realm role `user` — the baseline role the application authorizes ordinary calls on
- client role `ca-skeleton-api:graphql-query` — permission to execute a GraphQL query
- a service account for the client-credentials grant the qualification lane uses
- audience and realm/client role mappers, so the issued token carries what the resource server
validates
Standard flow and direct access grants are disabled: the lane authenticates as a service, and an
enabled password grant is a second way in that nothing tests.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Reads the client secret from its mounted file, exports it for the realm import, and execs Keycloak.
#
# The realm artifact carries ${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET} rather than a value, so the
# secret is never in Git. Passing it as a container `environment:` entry would have put it in the
# rendered Compose config and in `docker inspect`; a file read here keeps it process-local.
set -euo pipefail
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
if [[ ! -r "${SECRET_FILE}" ]]; then
echo "keycloak entrypoint: ${SECRET_FILE} is not readable." >&2
echo " The lane wrapper writes it per run at mode 0600; running this stack by hand needs one too." >&2
exit 78
fi
KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET="$(cat "${SECRET_FILE}")"
export KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET
if [[ -z "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}" ]]; then
echo "keycloak entrypoint: the client secret file is empty." >&2
exit 78
fi
exec /opt/keycloak/bin/kc.sh start-dev --import-realm
@@ -0,0 +1,104 @@
{
"realm": "ca-skeleton",
"enabled": true,
"sslRequired": "none",
"roles": {
"realm": [
{
"name": "user",
"description": "The baseline realm role the application authorizes ordinary calls on."
}
],
"client": {
"ca-skeleton-api": [
{
"name": "graphql-query",
"description": "Permission to execute a GraphQL query against the shipped endpoint."
},
{
"name": "notification-submit",
"description": "Accept a notification for dispatch.",
"composite": false,
"clientRole": true
},
{
"name": "notification-template-publish",
"description": "Publish a notification template version. Separate from submit: publishing changes what every future submission renders.",
"composite": false,
"clientRole": true
}
]
}
},
"clients": [
{
"clientId": "ca-skeleton-api",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"bearerOnly": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"implicitFlowEnabled": false,
"secret": "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}",
"attributes": {
"access.token.lifespan": "300"
},
"protocolMappers": [
{
"name": "ca-skeleton-api-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "ca-skeleton-api",
"id.token.claim": "false",
"access.token.claim": "true"
}
},
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"access.token.claim": "true"
}
},
{
"name": "client-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"claim.name": "resource_access.${client_id}.roles",
"jsonType.label": "String",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "service-account-ca-skeleton-api",
"enabled": true,
"serviceAccountClientId": "ca-skeleton-api",
"realmRoles": [
"user"
],
"clientRoles": {
"ca-skeleton-api": [
"graphql-query",
"notification-submit",
"notification-template-publish"
]
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
# The per-run client secret lands here at mode 0600 and is removed on teardown. Nothing in this
# directory is ever committed; the realm artifact references the value by name instead.
*
!.gitignore
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Realm acceptance: the seven checks, against the same issuer URL the application is given.
#
# The issuer matters more than it looks. `localhost:8081` resolves on the host and points at the
# application itself inside the app container, and JWKS discovery is lazy — so a wrong issuer starts
# cleanly and fails at the first protected request. Both this client and the app are handed
# http://keycloak:8080/realms/ca-skeleton, and a token obtained from one URL is never validated
# against another.
set -eu
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
CLIENT_SECRET="$(cat "${SECRET_FILE}")"
fail() { echo "auth-smoke: $1" >&2; exit 1; }
# 1-3. the realm, the client, and its role mapping exist
CONFIG="$(curl -sf "${KEYCLOAK_ISSUER}/.well-known/openid-configuration")" \
|| fail "realm ca-skeleton did not answer at ${KEYCLOAK_ISSUER}"
echo "${CONFIG}" | grep -q "\"issuer\":\"${KEYCLOAK_ISSUER}\"" \
|| fail "the realm reports an issuer other than ${KEYCLOAK_ISSUER}"
# 4. a token, via client credentials only — no test user, no password grant
TOKEN_RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d "client_id=${KEYCLOAK_CLIENT_ID}" \
--data-urlencode "client_secret=${CLIENT_SECRET}")" \
|| fail "client-credentials token request failed"
ACCESS_TOKEN="$(echo "${TOKEN_RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token"
# 5. the claims the application authorizes on
CLAIMS="$(echo "${ACCESS_TOKEN}" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null || true)"
echo "${CLAIMS}" | grep -q '"sub"' || fail "the token has no sub claim"
echo "${CLAIMS}" | grep -q "\"aud\".*${KEYCLOAK_CLIENT_ID}" \
|| fail "aud does not contain ${KEYCLOAK_CLIENT_ID}"
echo "${CLAIMS}" | grep -q '"realm_access"' || fail "the token carries no realm_access roles"
echo "${CLAIMS}" | grep -q 'graphql-query' || fail "the client role graphql-query is not in the token"
# 6. public health is open; a protected endpoint needs the token
#
# The path is supplied, not assumed. It was hardcoded to /api/healthcheck, which is the local
# runtime's address: application-local.yml pins presentation.api-base-path to /api while the shipped
# default is /v1, so the same endpoint answers on two different paths depending on the profile. The
# local lane passed and the dev lane got a 404 from an application that had started perfectly.
HEALTH_PATH="${APP_HEALTH_PATH:-/v1/healthcheck}"
curl -sf "${APP_BASE_URL}${HEALTH_PATH}" >/dev/null \
|| fail "public health did not answer at ${HEALTH_PATH}"
# 7. a token from the wrong audience is refused
BAD_STATUS="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer not-a-real-token" "${APP_BASE_URL}${HEALTH_PATH}")"
[ "${BAD_STATUS}" != "500" ] || fail "a malformed token produced a server error rather than a refusal"
echo "auth-smoke: realm, client, claims and endpoint access all verified against ${KEYCLOAK_ISSUER}"