feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
@@ -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.
|
||||
Executable
+23
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
Executable
+54
@@ -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}"
|
||||
Reference in New Issue
Block a user