diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 02e1e9e9f9..571aa1cd0c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,7 +19,7 @@ agents when editing matching file types (`applyTo` in each file's YAML frontmatt | `rust-database.instructions.md` | `crate/server_database/**/*.rs` | | `database-tables.instructions.md` | `crate/server_database/src/stores/sql/*.sql` | | `ui-routes.instructions.md` | `ui/src/App.tsx`, `ui/src/menuItems.tsx`, `ui/src/actions/**/*.tsx`, `ui/src/pages/**/*.tsx` | -| `routes.instructions.md` | `crate/server/src/routes/**/*.rs`, `crate/server/documentation/openapi.yaml` | +| `routes.instructions.md` | `crate/server/src/routes/**/*.rs` | | `kmip-operations.instructions.md` | `crate/kmip/src/**/*.rs`, `crate/server/src/core/operations/**/*.rs` | | `cli-ui-sync.instructions.md` | `crate/clients/clap/**/*.rs`, `crate/clients/ckms/**/*.rs`, `ui/src/actions/**/*.ts`, `ui/src/actions/**/*.tsx` | | `wasm.instructions.md` | `crate/clients/wasm/**/*.rs` | diff --git a/.github/instructions/routes.instructions.md b/.github/instructions/routes.instructions.md index f3c89271cf..4379390f8b 100644 --- a/.github/instructions/routes.instructions.md +++ b/.github/instructions/routes.instructions.md @@ -1,7 +1,7 @@ --- name: 'REST Routes & OpenAPI' description: 'Keep handlers, route registration, middleware, and the OpenAPI spec in sync when adding a REST endpoint' -applyTo: 'crate/server/src/routes/**/*.rs, crate/server/documentation/openapi.yaml' +applyTo: 'crate/server/src/routes/**/*.rs' --- # REST endpoint sync diff --git a/.github/workflows/test_all.yml b/.github/workflows/test_all.yml index a1f27febf8..709d9a5435 100644 --- a/.github/workflows/test_all.yml +++ b/.github/workflows/test_all.yml @@ -42,6 +42,7 @@ jobs: - secret_cosmian_kms - spire - kmip-go + - opa_rbac - ocsp - pki-revocation features: [fips, non-fips] @@ -84,6 +85,8 @@ jobs: # spire relies on the Vault API which is non-fips only - type: spire features: fips + - type: opa_rbac + features: fips include: # Docker services required per test type (types without an entry need no containers) - type: psql diff --git a/.mise/scripts/docs/generate_docs.sh b/.mise/scripts/docs/generate_docs.sh index f336805e1d..a43db33774 100755 --- a/.mise/scripts/docs/generate_docs.sh +++ b/.mise/scripts/docs/generate_docs.sh @@ -77,7 +77,8 @@ if [[ $# -gt 0 && "$1" != --* ]]; then fi case "$TASK" in - all) ;; + all) + ;; server-docs) SKIP_CKMS=true SKIP_KMIP=true diff --git a/.mise/scripts/docs/update_log_index.py b/.mise/scripts/docs/update_log_index.py old mode 100644 new mode 100755 diff --git a/.mise/scripts/nix.sh b/.mise/scripts/nix.sh index 4103d82ec9..482cc84c3b 100755 --- a/.mise/scripts/nix.sh +++ b/.mise/scripts/nix.sh @@ -36,6 +36,7 @@ usage() { pykmip Run all PyKMIP operations + Synology DSM simulation (non-FIPS) openssh Run OpenSSH PKCS#11 integration tests (non-FIPS) luks Run LUKS disk-encryption PKCS#11 integration tests + opa_rbac Run OPA RBAC end-to-end tests (requires Docker for OPA) otel_export Run OTEL export tests (requires Docker) Alias: 'otel' (backward-compatible) iris Run IRIS ↔ KMS mTLS integration tests (requires Docker + IRIS image) @@ -491,6 +492,9 @@ test_command() { jose) SCRIPT="$REPO_ROOT/.mise/scripts/test/test_jose.sh" ;; + opa_rbac) + SCRIPT="$REPO_ROOT/.mise/scripts/test/test_opa_rbac.sh" + ;; iris) SCRIPT="$REPO_ROOT/.mise/scripts/test/test_iris.sh" ;; @@ -587,7 +591,7 @@ test_command() { ;; *) echo "Error: Unknown test type '$TEST_TYPE'" >&2 - echo "Valid types: aws_xks, sqlite, mysql, percona, mariadb, psql, redis, google_cse, gcp_cmek, pykmip, openssh, luks, otel_export, iris, jose, hsm [softhsm2|utimaco|proteccio|all], ui, secret_vault, secret_aws, secret_azure, secret_cosmian_kms" >&2 + echo "Valid types: aws_xks, sqlite, mysql, percona, mariadb, psql, redis, google_cse, gcp_cmek, pykmip, openssh, luks, otel_export, iris, opa_rbac, jose, hsm [softhsm2|utimaco|proteccio|all], ui, secret_vault, secret_aws, secret_azure, secret_cosmian_kms" >&2 usage ;; esac @@ -632,7 +636,7 @@ test_command() { fi # Ensure curl is present for test types that use HTTP readiness probes # or curl-based integration helpers inside the nix-shell. - if [ "$TEST_TYPE" = "azure_ekm" ] || [ "$TEST_TYPE" = "ui" ] || [ "$TEST_TYPE" = "all" ] || [ "$TEST_TYPE" = "gcp_cmek" ] || [ "$TEST_TYPE" = "openssh" ] || [ "$TEST_TYPE" = "luks" ] || [ "$TEST_TYPE" = "jose" ]; then + if [ "$TEST_TYPE" = "azure_ekm" ] || [ "$TEST_TYPE" = "ui" ] || [ "$TEST_TYPE" = "all" ] || [ "$TEST_TYPE" = "gcp_cmek" ] || [ "$TEST_TYPE" = "openssh" ] || [ "$TEST_TYPE" = "luks" ] || [ "$TEST_TYPE" = "jose" ] || [ "$TEST_TYPE" = "opa_rbac" ]; then export WITH_CURL=1 fi @@ -1307,6 +1311,21 @@ run_in_nix_shell() { CMD="export VARIANT='$VARIANT' LINK='$LINK' RELEASE_FLAG='$RELEASE_FLAG' BUILD_PROFILE='$BUILD_PROFILE'; bash '$SCRIPT' --variant '$VARIANT' --link '$LINK'" + # opa_rbac runs inside a pure nix-shell but needs the system docker binary to + # start the OPA container and system curl (for plain HTTP OPA queries). Resolve + # docker's parent directory here (before entering the pure shell that strips + # system PATH), then APPEND it AFTER the Nix PATH. Appending keeps all Nix + # binaries (cargo, rustc, gcc, …) at higher priority while still making docker + # and curl available as fallbacks — avoiding the shadow problem where a prepended + # /usr/bin/cargo (system Rust 1.85) would override the Nix cargo (1.93.1). + if [ "${TEST_TYPE:-}" = "opa_rbac" ]; then + DOCKER_BIN="$(command -v docker 2>/dev/null || true)" + if [ -n "$DOCKER_BIN" ]; then + DOCKER_DIR="$(dirname "$DOCKER_BIN")" + CMD="export PATH=\"\${PATH}:${DOCKER_DIR}\"; ${CMD}" + fi + fi + ARGSTR_VARIANT="" if [ "$SHELL_PATH" = "$REPO_ROOT/shell.nix" ]; then ARGSTR_VARIANT="--argstr variant $VARIANT" diff --git a/.mise/scripts/test/test_opa_rbac.sh b/.mise/scripts/test/test_opa_rbac.sh new file mode 100755 index 0000000000..b42f4b83f8 --- /dev/null +++ b/.mise/scripts/test/test_opa_rbac.sh @@ -0,0 +1,668 @@ +#!/usr/bin/env bash +# OPA RBAC end-to-end test suite. +# +# Two-phase testing strategy: +# +# Phase 1 — Policy tests (OPA only, no KMS): +# Queries OPA /v1/data/kms/allow directly with crafted inputs. +# Covers all roles × operations × domain scenarios without a running KMS. +# +# Phase 2 — Integration tests (KMS + OPA): +# Starts KMS with --features insecure (accepts unsigned JWTs) and --opa-mode enforcing. +# Verifies that the full HTTP → KMS → OPA → allow/deny stack works correctly. +# +# IMPORTANT: For KMIP operations, HTTP status codes do NOT reflect OPA decisions: +# - Missing/invalid JWT → HTTP 401 (JWT auth middleware, before KMS handler) +# - OPA allows the operation → HTTP 200 + KMIP ResultStatus "Success" +# - OPA denies the operation → HTTP 200 + KMIP ResultStatus "OperationFailed" +# This is because KMIP errors are always wrapped in HTTP 200 per the KMIP protocol. +# +# Requires: cargo, docker (for OPA container), curl +set -euo pipefail +set -x + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +source "${SCRIPT_DIR}/../common.sh" + +init_build_env "$@" +setup_test_logging + +# ── Configuration ──────────────────────────────────────────────────────────── +KMS_PORT=9991 +KMS_URL="http://127.0.0.1:${KMS_PORT}" +OPA_PORT=8182 # separate port to avoid conflict with docker-compose opa on 8181 +OPA_URL="http://127.0.0.1:${OPA_PORT}" +OPA_CONTAINER="kms-opa-rbac-test-$$" + +KMS_PID="" +SQLITE_PATH="" +KMS_CONF_PATH="" + +cleanup() { + [ -n "${KMS_PID:-}" ] && { + kill "${KMS_PID}" 2>/dev/null || true + wait "${KMS_PID}" 2>/dev/null || true + } + docker rm -f "${OPA_CONTAINER}" 2>/dev/null || true + [ -n "${SQLITE_PATH:-}" ] && { rm -rf "${SQLITE_PATH}" || true; } + [ -n "${KMS_CONF_PATH:-}" ] && { rm -f "${KMS_CONF_PATH}" || true; } +} +trap cleanup EXIT + +# ── JWT helpers ─────────────────────────────────────────────────────────────── +# Craft an unsigned JWT accepted by KMS when built with --features insecure. +# insecure_decode() only deserializes the payload; it never verifies the signature. + +b64url() { + printf '%s' "$1" | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n' +} + +# make_jwt +# Example: make_jwt "alice@acme.com" "acme.com" '["CryptoOfficer"]' +make_jwt() { + local sub="$1" domain="$2" roles="$3" + local header='{"alg":"RS256","typ":"JWT"}' + local payload + payload=$(printf '{"sub":"%s","iss":"test","iat":1000000,"exp":9999999999,"roles":%s,"as_domain":"%s"}' \ + "$sub" "$roles" "$domain") + printf '%s.%s.fakesig' "$(b64url "$header")" "$(b64url "$payload")" +} + +# ── KMIP request helpers ────────────────────────────────────────────────────── +# Both kmip_* helpers strip LD_LIBRARY_PATH before calling curl so the system +# curl does not load the FIPS OpenSSL 3.1.2 shared library (which is older than +# the OpenSSL the system libcurl was compiled against). +# Returns the HTTP status code for a KMIP /kmip/2_1 POST. +kmip_post_status() { + local jwt="$1" body="$2" + if [ -n "$jwt" ]; then + env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${KMS_URL}/kmip/2_1" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${jwt}" \ + -d "$body" + else + env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${KMS_URL}/kmip/2_1" \ + -H "Content-Type: application/json" \ + -d "$body" + fi +} + +# Returns the raw KMIP response body (JSON TTLV). +kmip_response_body() { + local jwt="$1" body="$2" + if [ -n "$jwt" ]; then + env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -s \ + -X POST "${KMS_URL}/kmip/2_1" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${jwt}" \ + -d "$body" + else + env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -s \ + -X POST "${KMS_URL}/kmip/2_1" \ + -H "Content-Type: application/json" \ + -d "$body" + fi +} + +# Build the TTLV JSON for a Create symmetric key request. +create_aes256_request() { + cat <<'EOF' +{ + "tag": "RequestMessage", + "type": "Structure", + "value": [ + { + "tag": "RequestHeader", + "type": "Structure", + "value": [ + {"tag": "ProtocolVersion","type": "Structure","value": [ + {"tag": "ProtocolVersionMajor","type": "Integer","value": 2}, + {"tag": "ProtocolVersionMinor","type": "Integer","value": 1} + ]}, + {"tag": "BatchCount","type": "Integer","value": 1} + ] + }, + { + "tag": "BatchItem", + "type": "Structure", + "value": [ + {"tag": "Operation","type": "Enumeration","value": "Create"}, + { + "tag": "RequestPayload", + "type": "Structure", + "value": [ + {"tag": "ObjectType","type": "Enumeration","value": "SymmetricKey"}, + { + "tag": "Attributes", + "type": "Structure", + "value": [ + {"tag": "CryptographicAlgorithm","type": "Enumeration","value": "AES"}, + {"tag": "CryptographicLength","type": "Integer","value": 256}, + {"tag": "CryptographicUsageMask","type": "Integer","value": 2108} + ] + } + ] + } + ] + } + ] +} +EOF +} + +# ── Precondition checks ─────────────────────────────────────────────────────── + +require_cmd docker "Docker is required to run the OPA container." +require_cmd curl "curl is required for HTTP requests." + +echo "=========================================" +echo "Running OPA RBAC end-to-end tests" +echo "Variant: ${VARIANT_NAME}" +echo "=========================================" + +# ── Step 1: Start OPA container ─────────────────────────────────────────────── +echo "==> Starting OPA container on port ${OPA_PORT}..." + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +REGO_FILE="${REPO_ROOT}/test_data/opa/kms.rego" + +if [ ! -f "${REGO_FILE}" ]; then + echo "ERROR: OPA policy file not found: ${REGO_FILE}" >&2 + exit 1 +fi + +docker run -d \ + --name "${OPA_CONTAINER}" \ + -p "${OPA_PORT}:8181" \ + -v "${REGO_FILE}:/policies/kms.rego:ro" \ + openpolicyagent/opa:edge-static-debug \ + run --server --log-level=error --addr=0.0.0.0:8181 /policies/kms.rego + +echo "==> Waiting for OPA to be ready..." +# Unset LD_LIBRARY_PATH for curl: the Nix FIPS shell sets LD_LIBRARY_PATH to OpenSSL 3.1.2, +# but the system curl requires a newer OpenSSL ABI (3.3+), causing a version mismatch. +# OPA communication is plain HTTP — no TLS — so resetting the OpenSSL env vars is safe. +for i in $(seq 1 30); do + if env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -sf "${OPA_URL}/health" >/dev/null 2>&1; then + echo "OPA ready." + break + fi + [ "$i" -eq 30 ] && { + echo "ERROR: OPA failed to start after 30s" >&2 + exit 1 + } + sleep 1 +done + +# Smoke-test OPA policy is loaded +POLICY_COUNT=$(env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -sf "${OPA_URL}/v1/policies" | grep -c '"id"' || true) +if [ "${POLICY_COUNT}" -lt 1 ]; then + echo "ERROR: OPA loaded no policies. Check ${REGO_FILE}" >&2 + exit 1 +fi +echo "OPA policy loaded (${POLICY_COUNT} polic(ies))." + +# ── Step 2: Phase 1 — Direct OPA policy tests ──────────────────────────────── +# Query OPA directly (no KMS) to validate the Rego policy logic in isolation. +PASS=0 +FAIL=0 + +opa_input() { + local user="$1" user_domain="$2" roles="$3" operation="$4" object_uid="$5" object_domain="$6" is_owner="$7" + printf '{"input":{"user":"%s","user_domain":"%s","roles":%s,"operation":"%s","object_uid":"%s","object_domain":"%s","is_owner":%s}}' \ + "$user" "$user_domain" "$roles" "$operation" "$object_uid" "$object_domain" "$is_owner" +} + +# Assert OPA allow result. +# Usage: assert_opa +assert_opa() { + local desc="$1" expected="$2" input="$3" + local response actual + response=$(env -u LD_LIBRARY_PATH -u LD_PRELOAD curl -sf "${OPA_URL}/v1/data/kms/allow" \ + -H "Content-Type: application/json" \ + -d "$input" || echo '{}') + # OPA returns {"result": true} or {"result": false} or {} when result is undefined (=false) + if echo "$response" | grep -q '"result":true'; then + actual="true" + else + actual="false" + fi + if [ "$actual" = "$expected" ]; then + echo " PASS: ${desc} → ${actual}" + PASS=$((PASS + 1)) + else + echo " FAIL: ${desc} — expected ${expected}, got response: ${response}" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Phase 1: OPA policy unit tests" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# SuperAdmin — unrestricted across domains and operations +assert_opa "SuperAdmin can create in own domain" "true" \ + "$(opa_input "sa@acme.com" "acme.com" '["SuperAdmin"]' "create" "*" "acme.com" "false")" +assert_opa "SuperAdmin can create in other domain" "true" \ + "$(opa_input "sa@acme.com" "acme.com" '["SuperAdmin"]' "create" "*" "other.com" "false")" +assert_opa "SuperAdmin can destroy in any domain" "true" \ + "$(opa_input "sa@acme.com" "acme.com" '["SuperAdmin"]' "destroy" "uid-1" "other.com" "false")" + +# DomainAdmin — full control within own domain +assert_opa "DomainAdmin can create in own domain" "true" \ + "$(opa_input "da@acme.com" "acme.com" '["DomainAdmin"]' "create" "*" "acme.com" "false")" +assert_opa "DomainAdmin denied in other domain" "false" \ + "$(opa_input "da@acme.com" "acme.com" '["DomainAdmin"]' "get" "uid-1" "other.com" "false")" + +# CryptoOfficer — key lifecycle in own domain +assert_opa "CryptoOfficer can create in own domain" "true" \ + "$(opa_input "co@acme.com" "acme.com" '["CryptoOfficer"]' "create" "*" "acme.com" "false")" +assert_opa "CryptoOfficer can destroy in own domain" "true" \ + "$(opa_input "co@acme.com" "acme.com" '["CryptoOfficer"]' "destroy" "uid-1" "acme.com" "false")" +assert_opa "CryptoOfficer denied in other domain" "false" \ + "$(opa_input "co@acme.com" "acme.com" '["CryptoOfficer"]' "create" "*" "other.com" "false")" +assert_opa "CryptoOfficer denied encrypt (User-only op)" "false" \ + "$(opa_input "co@acme.com" "acme.com" '["CryptoOfficer"]' "encrypt" "*" "acme.com" "false")" + +# Auditor — read-only metadata in own domain +assert_opa "Auditor can get_attributes in own domain" "true" \ + "$(opa_input "au@acme.com" "acme.com" '["Auditor"]' "get_attributes" "uid-1" "acme.com" "false")" +assert_opa "Auditor denied create" "false" \ + "$(opa_input "au@acme.com" "acme.com" '["Auditor"]' "create" "*" "acme.com" "false")" +assert_opa "Auditor denied destroy" "false" \ + "$(opa_input "au@acme.com" "acme.com" '["Auditor"]' "destroy" "uid-1" "acme.com" "false")" +assert_opa "Auditor denied in other domain" "false" \ + "$(opa_input "au@acme.com" "acme.com" '["Auditor"]' "get_attributes" "uid-1" "other.com" "false")" + +# User — crypto-use only, no lifecycle +assert_opa "User can encrypt in own domain" "true" \ + "$(opa_input "u@acme.com" "acme.com" '["User"]' "encrypt" "uid-1" "acme.com" "false")" +assert_opa "User can decrypt in own domain" "true" \ + "$(opa_input "u@acme.com" "acme.com" '["User"]' "decrypt" "uid-1" "acme.com" "false")" +assert_opa "User denied create" "false" \ + "$(opa_input "u@acme.com" "acme.com" '["User"]' "create" "*" "acme.com" "false")" +assert_opa "User denied destroy" "false" \ + "$(opa_input "u@acme.com" "acme.com" '["User"]' "destroy" "uid-1" "acme.com" "false")" +assert_opa "User denied in other domain" "false" \ + "$(opa_input "u@acme.com" "acme.com" '["User"]' "encrypt" "uid-1" "other.com" "false")" + +# No role — everything denied +assert_opa "No role denied create" "false" \ + "$(opa_input "anon@acme.com" "acme.com" '[]' "create" "*" "acme.com" "false")" +assert_opa "No role denied encrypt" "false" \ + "$(opa_input "anon@acme.com" "acme.com" '[]' "encrypt" "uid-1" "acme.com" "false")" + +# Owner rule — owner always allowed regardless of role +assert_opa "Owner always allowed (no role)" "true" \ + "$(opa_input "anon@acme.com" "acme.com" '[]' "destroy" "uid-1" "acme.com" "true")" + +echo "" +echo "Phase 1 results: ${PASS} passed, ${FAIL} failed" + +PHASE1_PASS=$PASS +PHASE1_FAIL=$FAIL + +# ── Step 3: Build KMS with insecure feature ─────────────────────────────────── +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Phase 2: Integration tests (KMS + OPA)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "==> Building KMS with --features insecure..." +# insecure enables dangerous::insecure_decode — accepts unsigned JWTs (test only) +# +# In the FIPS nix-shell, LD_LIBRARY_PATH is set to FIPS OpenSSL 3.1.2. The +# system libcurl needs OpenSSL 3.2+; CARGO_NET_OFFLINE prevents registry access +# to avoid the libcurl ABI mismatch when cargo checks the network. +# +# We must pre-fetch all crate sources BEFORE setting CARGO_NET_OFFLINE, because +# on a fresh CI runner ~/.cargo/registry may be empty. cargo fetch --locked +# downloads everything into the registry cache; the subsequent offline build then +# finds all packages without hitting the network. +# +# The user's ~/.cargo/config.toml may set clang as linker and sccache as the +# rustc-wrapper. clang is not in the pure Nix PATH, so linker is set to cc. +# RUSTC_WRAPPER is cleared so sccache (at an absolute system path) is not used. +# The mold linker flag (-fuse-ld=mold) is handled by adding pkgs.mold to +# shell.nix buildInputs so the Nix-native mold is found before /usr/bin/ld.mold. +echo "cargo: $(command -v cargo) ($(cargo --version 2>&1 | head -1))" +echo "==> Fetching crate dependencies (online)..." +CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc \ + RUSTC_WRAPPER="" \ + cargo fetch --locked +# shellcheck disable=SC2068 +CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc \ + RUSTC_WRAPPER="" \ + CARGO_NET_OFFLINE=true \ + cargo build ${FEATURES_FLAG[@]+${FEATURES_FLAG[@]}} --features insecure --bin cosmian_kms + +# ── Step 4: Start KMS ───────────────────────────────────────────────────────── +SQLITE_PATH="$(mktemp -d -t kms-opa-rbac-XXXXXX)" +KMS_CONF_PATH="$(mktemp -t kms-opa-rbac-conf-XXXXXX.toml)" + +# jwt_auth_provider format: "issuer,jwks_uri,audience1,audience2" +# With --features insecure, jwt signature and expiry are not verified. +# Any non-empty issuer value works; the JWKS URI is never fetched. +cat >"${KMS_CONF_PATH}" < Starting KMS (enforcing OPA mode)..." +# shellcheck disable=SC2068 +CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc \ + RUSTC_WRAPPER="" \ + CARGO_NET_OFFLINE=true \ + cargo run ${FEATURES_FLAG[@]+${FEATURES_FLAG[@]}} --features insecure --bin cosmian_kms -- \ + --config "${KMS_CONF_PATH}" & +KMS_PID=$! + +if ! _wait_for_port "127.0.0.1" "${KMS_PORT}" 60; then + echo "ERROR: KMS failed to start on port ${KMS_PORT}" >&2 + exit 1 +fi +echo "KMS started (PID=${KMS_PID})." + +# ── Step 5: Craft test JWTs ─────────────────────────────────────────────────── +echo "==> Crafting test JWTs..." +JWT_SUPER_ADMIN=$(make_jwt "superadmin@acme.com" "acme.com" '["SuperAdmin"]') +JWT_DOMAIN_ADMIN=$(make_jwt "domainadmin@acme.com" "acme.com" '["DomainAdmin"]') +JWT_CRYPTO_OFF=$(make_jwt "officer@acme.com" "acme.com" '["CryptoOfficer"]') +JWT_AUDITOR=$(make_jwt "auditor@acme.com" "acme.com" '["Auditor"]') +JWT_USER=$(make_jwt "user@acme.com" "acme.com" '["User"]') +JWT_NO_ROLE=$(make_jwt "anon@acme.com" "acme.com" '[]') + +CREATE_REQUEST=$(create_aes256_request) + +# ── Step 6: Integration assertions ─────────────────────────────────────────── +PASS=0 +FAIL=0 + +# assert_kmip_http: check the HTTP status code (for auth-layer failures). +assert_kmip_http() { + local desc="$1" expected_http="$2" jwt="$3" body="$4" + local actual_http + actual_http=$(kmip_post_status "$jwt" "$body") + if [ "$actual_http" = "$expected_http" ]; then + echo " PASS: ${desc} → HTTP ${actual_http}" + PASS=$((PASS + 1)) + else + echo " FAIL: ${desc} — expected HTTP ${expected_http}, got HTTP ${actual_http}" + FAIL=$((FAIL + 1)) + fi +} + +# assert_kmip_success: check that HTTP 200 + KMIP ResultStatus "Success". +assert_kmip_success() { + local desc="$1" jwt="$2" body="$3" + local resp + resp=$(kmip_response_body "$jwt" "$body") + local actual_http + actual_http=$(kmip_post_status "$jwt" "$body") + if [ "$actual_http" != "200" ]; then + echo " FAIL: ${desc} — expected HTTP 200, got HTTP ${actual_http}" + FAIL=$((FAIL + 1)) + return + fi + if echo "$resp" | grep -q '"Success"'; then + echo " PASS: ${desc} → HTTP 200 + KMIP Success" + PASS=$((PASS + 1)) + else + echo " FAIL: ${desc} — HTTP 200 but KMIP body has no 'Success': ${resp}" + FAIL=$((FAIL + 1)) + fi +} + +# assert_kmip_denied: check that HTTP 200 + KMIP ResultStatus "OperationFailed". +# OPA-denied operations produce a KMIP error body, not an HTTP error. +assert_kmip_denied() { + local desc="$1" jwt="$2" body="$3" + local resp + resp=$(kmip_response_body "$jwt" "$body") + local actual_http + actual_http=$(kmip_post_status "$jwt" "$body") + if [ "$actual_http" != "200" ]; then + echo " FAIL: ${desc} — expected HTTP 200 (KMIP error body), got HTTP ${actual_http}" + FAIL=$((FAIL + 1)) + return + fi + if echo "$resp" | grep -q '"OperationFailed"'; then + echo " PASS: ${desc} → HTTP 200 + KMIP OperationFailed" + PASS=$((PASS + 1)) + else + echo " FAIL: ${desc} — HTTP 200 but KMIP body has no 'OperationFailed': ${resp}" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "── Auth-layer tests (JWT middleware, HTTP status) ────────────────────────" +assert_kmip_http "No JWT → HTTP 401 (auth middleware)" "401" "" "$CREATE_REQUEST" + +echo "" +echo "── Positive KMIP tests (OPA allows → KMIP Success) ──────────────────────" +assert_kmip_success "SuperAdmin can create key" "$JWT_SUPER_ADMIN" "$CREATE_REQUEST" +assert_kmip_success "DomainAdmin can create key" "$JWT_DOMAIN_ADMIN" "$CREATE_REQUEST" +assert_kmip_success "CryptoOfficer can create key" "$JWT_CRYPTO_OFF" "$CREATE_REQUEST" + +echo "" +echo "── Negative KMIP tests (OPA denies → KMIP OperationFailed) ──────────────" +echo " Note: HTTP is always 200; OPA denial is expressed in the KMIP response body." +assert_kmip_denied "Auditor denied create" "$JWT_AUDITOR" "$CREATE_REQUEST" +assert_kmip_denied "User denied create" "$JWT_USER" "$CREATE_REQUEST" +assert_kmip_denied "No role denied create" "$JWT_NO_ROLE" "$CREATE_REQUEST" + +# ── Step 7: Cross-domain integration test ───────────────────────────────────── +# Create a key as acme.com CryptoOfficer, then verify another-domain officer cannot destroy it. +echo "" +echo "── Cross-domain test ────────────────────────────────────────────────────" +echo "==> Creating a key as CryptoOfficer in acme.com domain..." +CREATE_RESP=$(kmip_response_body "$JWT_CRYPTO_OFF" "$CREATE_REQUEST") +KEY_UID=$(printf '%s' "$CREATE_RESP" | grep -o '"UniqueIdentifier".*"value":"[^"]*"' | + grep -o '"value":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + +if [ -z "${KEY_UID:-}" ]; then + echo " SKIP: Could not extract UID from Create response; skipping cross-domain test" + echo " Response: ${CREATE_RESP}" +else + echo " Created key UID: ${KEY_UID}" + JWT_OTHER_DOMAIN=$(make_jwt "officer@other.com" "other.com" '["CryptoOfficer"]') + DESTROY_REQUEST=$(printf '{"tag":"RequestMessage","type":"Structure","value":[{"tag":"RequestHeader","type":"Structure","value":[{"tag":"ProtocolVersion","type":"Structure","value":[{"tag":"ProtocolVersionMajor","type":"Integer","value":2},{"tag":"ProtocolVersionMinor","type":"Integer","value":1}]},{"tag":"BatchCount","type":"Integer","value":1}]},{"tag":"BatchItem","type":"Structure","value":[{"tag":"Operation","type":"Enumeration","value":"Destroy"},{"tag":"RequestPayload","type":"Structure","value":[{"tag":"UniqueIdentifier","type":"TextString","value":"%s"}]}]}]}' \ + "$KEY_UID") + assert_kmip_denied "Cross-domain officer denied destroy on acme.com key" \ + "$JWT_OTHER_DOMAIN" "$DESTROY_REQUEST" +fi + +# ── Phase 2 intermediate results (Phase 3 adds its own summary below) ───────── +echo "" +echo "Phase 2 results: ${PASS} passed, ${FAIL} failed" + +echo "OPA RBAC tests completed successfully." + +# ── Phase 3: Rust integration tests (vector_runner.rs `test_vec_opa_*`) ─────── +# +# Requires the Cosmian Authentication Verifier binary to be built. +# Build with: cargo build -p auth_verifier --manifest-path authentication/Cargo.toml +# +# If the binary is not available, Phase 3 is skipped (with a prominent warning). +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Phase 3: Rust vector_runner.rs integration tests (real auth server)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +AUTH_SERVER_PORT=8444 # Use 8444 to avoid conflict with any running 8443 +AUTH_SERVER_URL="https://127.0.0.1:${AUTH_SERVER_PORT}" +AUTH_SERVER_PID="" +AUTH_VERIFIER_BIN="" + +# Locate auth_verifier binary (prefer release, fall back to debug). +for candidate in \ + "${REPO_ROOT}/authentication/target/release/auth_verifier" \ + "${REPO_ROOT}/authentication/target/debug/auth_verifier"; do + if [[ -x "${candidate}" ]]; then + AUTH_VERIFIER_BIN="${candidate}" + break + fi +done + +if [[ -z "${AUTH_VERIFIER_BIN}" ]]; then + echo "WARNING: auth_verifier binary not found. Attempting to build..." + if cargo build -p auth_verifier \ + --manifest-path "${REPO_ROOT}/authentication/Cargo.toml" 2>&1; then + AUTH_VERIFIER_BIN="${REPO_ROOT}/authentication/target/debug/auth_verifier" + else + echo "WARNING: Could not build auth_verifier. Phase 3 SKIPPED." + echo " To enable: cargo build -p auth_verifier --manifest-path authentication/Cargo.toml" + PHASE3_SKIPPED=true + fi +fi + +if [[ "${PHASE3_SKIPPED:-false}" != "true" ]]; then + # ── Create a dedicated auth verifier config for integration tests ───────── + AUTH_VERIFIER_CONF=$(mktemp -t kms-opa-auth-XXXXXX.toml) + CA_CERT="${REPO_ROOT}/authentication/server/src/tests/certificates/ec/auth.ca.pem" + # Update cleanup to also stop the auth server + cleanup_phase3() { + [ -n "${AUTH_SERVER_PID:-}" ] && { + kill "${AUTH_SERVER_PID}" 2>/dev/null || true + wait "${AUTH_SERVER_PID}" 2>/dev/null || true + } + [ -n "${AUTH_VERIFIER_CONF:-}" ] && rm -f "${AUTH_VERIFIER_CONF}" || true + # Remove ephemeral auth DB + rm -f /tmp/kms_opa_integration_auth.db 2>/dev/null || true + } + trap cleanup_phase3 EXIT + + # Write a minimal auth verifier config (dev mode, ephemeral SQLite). + cat >"${AUTH_VERIFIER_CONF}" < Starting auth verifier on port ${AUTH_SERVER_PORT}..." + # Must run from authentication/ so relative cert paths resolve correctly. + (cd "${REPO_ROOT}/authentication" && + "${AUTH_VERIFIER_BIN}" "${AUTH_VERIFIER_CONF}") \ + >"${REPO_ROOT}/target/auth_verifier_integration.log" 2>&1 & + AUTH_SERVER_PID=$! + + # Wait for auth verifier to be ready (HTTPS health check). + echo "==> Waiting for auth verifier to be ready..." + ready=false + for i in $(seq 1 30); do + if env -u LD_LIBRARY_PATH -u LD_PRELOAD \ + curl -sk --cacert "${CA_CERT}" \ + "${AUTH_SERVER_URL}/health" >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 + done + if [[ "${ready}" != "true" ]]; then + echo "ERROR: auth verifier failed to start after 30s" >&2 + cat "${REPO_ROOT}/target/auth_verifier_integration.log" >&2 + exit 1 + fi + echo "Auth verifier ready (PID=${AUTH_SERVER_PID})." + + # ── Provision users ─────────────────────────────────────────────────────── + echo "==> Provisioning auth verifier test users..." + PROVISION_SCRIPT="${REPO_ROOT}/test_data/configs/auth_verifier/provision_opa_users.sh" + eval "$(AUTH_URL="${AUTH_SERVER_URL}" CA_CERT="${CA_CERT}" \ + REPO_ROOT="${REPO_ROOT}" bash "${PROVISION_SCRIPT}")" + echo "Provisioning complete." + echo " KMS_TEST_OPA_SUPER_ADMIN_JWT set (length ${#KMS_TEST_OPA_SUPER_ADMIN_JWT})" + echo " KMS_TEST_OPA_OFFICER_JWT set (length ${#KMS_TEST_OPA_OFFICER_JWT})" + echo " KMS_TEST_OPA_USER_ROLE_JWT set (length ${#KMS_TEST_OPA_USER_ROLE_JWT})" + echo " KMS_TEST_OPA_AUDITOR_JWT set (length ${#KMS_TEST_OPA_AUDITOR_JWT})" + echo " KMS_TEST_OPA_NO_ROLES_JWT set (length ${#KMS_TEST_OPA_NO_ROLES_JWT})" + echo " KMS_TEST_OPA_UNKNOWN_ROLE_JWT set (length ${#KMS_TEST_OPA_UNKNOWN_ROLE_JWT})" + echo " KMS_TEST_OPA_DOMAIN_ADMIN_OTHER_JWT set (length ${#KMS_TEST_OPA_DOMAIN_ADMIN_OTHER_JWT})" + echo " KMS_TEST_OPA_OTHER_DOMAIN_JWT set (length ${#KMS_TEST_OPA_OTHER_DOMAIN_JWT})" + + # ── Export required env vars for the Rust tests ─────────────────────────── + export KMS_OPA_URL="${OPA_URL}" + export KMS_AUTH_SERVER_URL="${AUTH_SERVER_URL}" + # JWT vars already exported by provision script eval above. + + # ── Run Rust vector_runner.rs OPA tests ─────────────────────────────────── + echo "" + echo "==> Running Rust OPA vector tests (--include-ignored -- test_vec_opa_)..." + PHASE3_FAIL=0 + + # shellcheck disable=SC2068 + if CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc \ + RUSTC_WRAPPER="" \ + CARGO_NET_OFFLINE=true \ + cargo test \ + ${FEATURES_FLAG[@]+${FEATURES_FLAG[@]}} \ + --features non-fips \ + -p test_kms_server \ + --lib \ + -- \ + --include-ignored \ + test_vec_opa_ \ + 2>&1 | tee /tmp/kms_opa_rust_tests.log; then + echo "Phase 3: Rust OPA tests PASSED." + else + PHASE3_FAIL=1 + echo "Phase 3: Rust OPA tests FAILED." >&2 + fi +fi + +# ── Final summary ───────────────────────────────────────────────────────────── +echo "" +echo "=========================================" +echo "OPA RBAC test summary:" +echo " Phase 1 (policy tests): ${PHASE1_PASS} pass / ${PHASE1_FAIL} fail" +echo " Phase 2 (integration) : ${PASS} pass / ${FAIL} fail" +if [[ "${PHASE3_SKIPPED:-false}" == "true" ]]; then + echo " Phase 3 (Rust tests) : SKIPPED (auth_verifier binary not available)" +else + echo " Phase 3 (Rust tests) : $([ "${PHASE3_FAIL}" -eq 0 ] && echo PASSED || echo FAILED)" +fi +echo "=========================================" + +TOTAL_FAIL=$((PHASE1_FAIL + FAIL + ${PHASE3_FAIL:-0})) +if [ "${TOTAL_FAIL}" -gt 0 ]; then + echo "ERROR: ${TOTAL_FAIL} OPA RBAC test(s) failed." >&2 + exit 1 +fi diff --git a/.mise/scripts/windows/cargo_test.ps1 b/.mise/scripts/windows/cargo_test.ps1 index 2acbe7fe1c..e1a6ff46c6 100644 --- a/.mise/scripts/windows/cargo_test.ps1 +++ b/.mise/scripts/windows/cargo_test.ps1 @@ -5,6 +5,9 @@ $PSNativeCommandUseErrorActionPreference = $true # might be true by default function TestProject { $env:RUST_LOG = "cosmian_kms_cli=error,cosmian_kms_server=error,cosmian_kmip=error,test_kms_server=error" + # Windows default thread stack is 1 MB; large async test functions overflow it in + # debug builds. 8 MB matches the Linux/macOS default (RUST_MIN_STACK is bytes). + $env:RUST_MIN_STACK = "8388608" # Add target rustup target add x86_64-pc-windows-msvc diff --git a/.mise/tasks/test/opa_rbac b/.mise/tasks/test/opa_rbac new file mode 100755 index 0000000000..de787725bf --- /dev/null +++ b/.mise/tasks/test/opa_rbac @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +#MISE description="Run OPA RBAC end-to-end tests (requires Docker for OPA)" +#USAGE flag "-v --variant " env="VARIANT" help="FIPS variant" default="non-fips" { +#USAGE choices "fips" "non-fips" +#USAGE } +#USAGE flag "-l --link " env="LINK" help="Linkage type" default="static" { +#USAGE choices "static" "dynamic" +#USAGE } +set -euo pipefail +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" +source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" +source "${MISE_CONFIG_ROOT}/.mise/lib/nix_helpers.sh" +kms_init_env "${usage_variant:-non-fips}" "${usage_link:-static}" +setup_test_logging +ensure_nix_shell + +print_header "Running OPA RBAC end-to-end tests (${VARIANT_NAME})" + +REPO_ROOT="$(get_repo_root)" + +bash "${REPO_ROOT}/.mise/scripts/test/test_opa_rbac.sh" --variant "$VARIANT" --link "$LINK" + +print_success "OPA RBAC tests completed" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b4158cbd87..5b928298e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -195,13 +195,6 @@ repos: stages: [pre-push] - id: clippy-all-targets stages: [manual] - types: [rust] - - id: cargo-format - name: cargo-format (post-clippy) - types: [rust] - stages: [pre-push] - - id: cargo-machete - types: [rust] # ═══════════════════════════════════════════════════════════════════════ # 4. Testing (slow, conditional on changed crate) @@ -648,6 +641,79 @@ repos: always_run: true stages: [manual] + # # Remove folders result* + # - id: clean-result-folders + # name: Clean result* folders + # entry: bash .mise/scripts/release/clean_result.sh + # language: system + # types: [rust] + # pass_filenames: false + # stages: [manual] + + - id: pnpm-ui-format + name: pnpm ui format + entry: pnpm -C ui check:format + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + + - id: gen-vector-readme + name: Regenerate test_kms_server vector README + description: | + Syncs crate/test_kms_server/README.md with vector_runner.rs and + test_data/vectors manifests. Exits 1 when the README is updated so + the developer must re-stage the file before committing. + entry: mise docs:vector-readme + language: system + pass_filenames: false + files: crate/test_kms_server/src/vector_runner\.rs|\.mise/scripts/docs/gen_vector_readme\.py|\.mise/tasks/docs/vector-readme + + - id: pnpm-ui-lint + name: pnpm ui check:lint + entry: pnpm -C ui check:lint + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + + - id: ui-wasm-react-unit-tests + name: UI wasm + React tests (incl. integration if Docker) + entry: mise run test:wasm + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + stages: [manual] + + - id: ui-e2e + name: UI end-to-end + entry: mise run test:ui + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + stages: [manual] + + - id: cargo-test-fips + name: cargo test (sqlite fips) + entry: cargo test --lib --workspace + language: system + types: [rust] + pass_filenames: false + stages: [manual] + + - id: cargo-test-non-fips + name: cargo test (sqlite non-fips) + entry: cargo test --lib --workspace --features non-fips + language: system + types: [rust] + pass_filenames: false + + - id: redis-cargo-test-non-fips + name: Redis and cargo test (non-fips) + entry: mise run test:redis -- --variant non-fips + language: system + types: [rust] + pass_filenames: false + stages: [manual] + - id: nix-build-all name: Nix build all derivations (ui → cli → server) entry: mise run release:nix-update-hashes @@ -663,3 +729,28 @@ repos: pass_filenames: false always_run: true stages: [manual] + + - repo: https://github.com/Cosmian/git-hooks.git + rev: v1.0.42 + hooks: + - id: nightly-clippy-autofix-unreachable-pub + stages: [manual] + - id: nightly-clippy-autofix-all-targets-all-features + stages: [manual] + - id: nightly-clippy-autofix-all-targets + stages: [manual] + + - id: dprint-toml-fix + stages: [manual] + - id: cargo-upgrade + stages: [manual] + - id: cargo-update + stages: [manual] + - id: cargo-format # in last du to clippy fixes + - id: docker-compose-down + + - repo: https://github.com/lycheeverse/lychee + rev: v0.15.1 + hooks: + - id: lychee + args: [--config, lychee.toml, 'documentation/docs/**/*.md'] diff --git a/AGENTS.md b/AGENTS.md index 6740131e64..c38171d1d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ The following files in `.github/instructions/` are automatically applied by agen | `rust-database.instructions.md` | `crate/server_database/**/*.rs` | SQLite, PostgreSQL, Redis-findex backends | | `database-tables.instructions.md` | `crate/server_database/src/stores/sql/*.sql` | Keep `documentation/docs/configuration/database/tables.md` in sync with SQL schema changes | | `ui-routes.instructions.md` | `ui/src/App.tsx`, `ui/src/menuItems.tsx`, `ui/src/actions/**/*.tsx`, `ui/src/pages/**/*.tsx` | Sync rule 4.1 — server SPA routes ⇔ React Router ⇔ menu items | -| `routes.instructions.md` | `crate/server/src/routes/**/*.rs`, `crate/server/documentation/openapi.yaml` | Sync rule 4.2 — REST endpoint handlers ⇔ OpenAPI ⇔ route registration | +| `routes.instructions.md` | `crate/server/src/routes/**/*.rs` | Sync rule 4.2 — REST endpoint handlers ⇔ OpenAPI ⇔ route registration | | `kmip-operations.instructions.md` | `crate/kmip/src/**/*.rs`, `crate/server/src/core/operations/**/*.rs` | Sync rule 4.3 — KMIP operation types ⇔ dispatcher ⇔ handler | | `cli-ui-sync.instructions.md` | `crate/clients/clap/**/*.rs`, `crate/clients/ckms/**/*.rs`, `ui/src/actions/**/*.ts`, `ui/src/actions/**/*.tsx` | Sync rules 4.4 + 4.15 — CLI ⇔ Web UI parity, CLI doc regeneration | | `wasm.instructions.md` | `crate/clients/wasm/**/*.rs` | Sync rule 4.5 — WASM exports ⇔ regenerated TS types ⇔ UI consumers | diff --git a/CHANGELOG/feat_split_key.md b/CHANGELOG/feat_split_key.md index 4afe753f0c..066d2f363e 100644 --- a/CHANGELOG/feat_split_key.md +++ b/CHANGELOG/feat_split_key.md @@ -64,8 +64,3 @@ Replaces the former `privileged_users` flat list with two FIPS 140-3 aligned rol - Key ceremony guide: two-role RBAC, XOR n-of-n, NIST references, Mermaid sequence diagrams, CLI quick reference. - Authorization reference: updated role matrix, operation tables, permission evaluation order. - -## CI / Tooling - -- **Windows CI** (`test_windows.yml`): added VS Ninja lookup step — resolves vcpkg build failures on Windows runners where Ninja is not on PATH by default. Fallback to Chocolatey if the VS installation does not include CMake/Ninja. -- **Multi-framework audit** (`.mise/scripts/audit/multi_framework.sh`): the script now also writes a Markdown report to `documentation/docs/certifications_and_compliance/audit/multi_framework_security_audit.md` alongside its console output. diff --git a/CHANGELOG/rbac_rego.md b/CHANGELOG/rbac_rego.md new file mode 100644 index 0000000000..63ceb31719 --- /dev/null +++ b/CHANGELOG/rbac_rego.md @@ -0,0 +1,75 @@ +# Features + +- Add OPA (Open Policy Agent) RBAC middleware integration with three modes: disabled, exclusive, enforcing +- Add `--opa-url` and `--opa-mode` CLI flags (env vars `KMS_OPA_URL`, `KMS_OPA_MODE`) to configure the OPA sidecar +- Add startup validation: `--opa-mode exclusive` or `--opa-mode enforcing` now fails with a clear error when `--opa-url` is not set (the server previously silently ignored the mode setting) +- Add KMIP-GO compliance tests for `CreateSplitKey` (§4.38) and `JoinSplitKey` (§4.39): share metadata assertions, full split-then-join roundtrip, threshold enforcement, and Query operation advertisement (`split_key_test.go`) +- Add `domain` column to the objects table across all database backends (SQLite, PostgreSQL, MySQL, Redis-findex) for domain-scoped access control +- Add `OpaClient` HTTP client with fail-closed design (deny on any transport/parse error) +- Add support for CSR-based Certify TTL: the `requested_validity_days` vendor attribute is now honoured on CSR-based `Certify` requests (the `build_and_sign_certificate` path already read it; this release adds regression tests and a matching `ttlDays int` parameter to `github.com/Cosmian/kmip-go`'s `Certify()` function, closing the SPIRE / kmip-go limitation documented at ) +- Wire OPA authorization into `user_has_permission()` with mode-dependent behavior: + - Exclusive: OPA is the sole decision maker + - Enforcing: both OPA and legacy KMS permission logic must allow +- Add `build_opa_input()` helper that constructs the OPA input document from request context +- Add `kms.rego` Rego policy supporting role-based domain-scoped access control +- Extract JWT `roles` and `as_domain` claims into `AuthenticatedUser` for OPA context +- Add per-request `OpaUserContext` thread-local to propagate roles/domain without threading through all function signatures + +## Documentation + +- Restructure `documentation/docs/configuration/authorization/` into 4 pages: + - `index.md`: overview, mode table, architecture diagram, role model, domain model, JWT claims, OPA input document, ceremony note, config reference + - `mode1.md`: native KMS permissions (ownership, grants, HSM, privileged users) with sequence and flowchart diagrams + - `mode2.md`: exclusive OPA mode with sequence diagram, Rego evaluation flowchart, fail-closed diagram, debug endpoint + - `mode3.md`: enforcing dual-gate mode with sequence diagram, compound decision flowchart, interaction scenarios table +- Remove `is_super_admin` from OPA input document — ceremony super-admin is fully decoupled from OPA (native KMS gate only) +- Remove `is_super_admin` allow rule and `super_admin_ceremony` reason from `kms.rego` +- Update `OPA-middleware.md` SA3 section to reflect full decoupling +- Update `documentation/mkdocs.yml` navigation + +## Refactor + +- Add `domain: String` field to `ObjectWithMetadata` struct +- Extend `ObjectsStore::create()` trait with `domain: &str` parameter +- Add schema migration for existing databases (ALTER TABLE ADD COLUMN domain) +- Add `roles: Vec` and `domain: Option` fields to `AuthenticatedUser` +- `UserClaim` (JWT deserialization) now includes `roles` and `domain` (alias `as_domain`) fields + +## Testing + +- Add 5 OPA integration test vectors under `test_data/vectors/opa/` covering mode 1 (disabled), mode 2 (exclusive), and mode 3 (enforcing), with both allowed and denied paths +- Add `setup_auth_server_for_opa()` helper that provisions the auth server (KMS_AUTH_SERVER_URL) with a `kms-opa-test` realm and `kms-opa-officer` user (CryptoOfficer role) via reqwest REST calls +- Add `get_or_init_opa_allowed_server()` and `get_or_init_opa_denied_server()` OnceCell helpers that start KMS servers patched with OPA config; tests skip gracefully when `KMS_OPA_URL` or `KMS_AUTH_SERVER_URL` env vars are absent +- Add `argon2` and `sha2` dev-dependencies to `test_kms_server` for computing Argon2id password hashes matching the auth server's formula +- Add `json` and `cookies` features to the `reqwest` dev-dependency for auth server REST calls +- Add 2 new OPA negative test vectors under `test_data/vectors/opa/`: + - `mode_exclusive_user_role_denied`: `User`-role JWT (non-owner) attempts `Get` on an officer's key → denied because `Get ∉ user_ops` in `kms.rego` + - `mode_exclusive_wrong_domain`: `CryptoOfficer` JWT in domain `kms-opa-other` attempts `Get` on a key created in domain `kms-opa-test` → denied because `same_domain` helper fails +- Extend `IdentityConfig` with `access_token_env: Option` to support JWT-based multi-identity test vectors (in addition to existing mTLS `client_cert`/`client_key`) +- Extend `build_identity_clients` to build JWT identity clients via `access_token_env` (reads named env var for the Bearer token; clears mTLS settings) +- Extend `setup_auth_server_for_opa` to provision 3 users: `kms-opa-officer` (CryptoOfficer, kms-opa-test), `kms-opa-user` (User, kms-opa-test), `kms-opa-other-officer` (CryptoOfficer, kms-opa-other); store extra JWTs in `KMS_TEST_OPA_USER_ROLE_JWT` / `KMS_TEST_OPA_OTHER_DOMAIN_JWT` +- Restructure `setup_auth_server_for_opa` to use separate admin (`cookie_store=true`) and login (cookieless) clients, preventing admin session cookie from being overwritten by user logins +- Add 3 more OPA test vectors (`mode_exclusive_auditor_destroy_denied`, `mode_exclusive_auditor_get_attributes_allowed`, `mode_exclusive_domain_admin_wrong_domain`) with JWT-based Auditor and DomainAdmin identities; now 11 OPA vectors total, all passing +- Add 4 multi-tenant isolation test vectors completing the cross-domain isolation matrix for all five RBAC roles: + - `mode_exclusive_auditor_wrong_domain`: Auditor (kms-opa-test) denied `GetAttributes` on a key owned by kms-opa-other — same_domain fails even for read-only metadata ops + - `mode_exclusive_user_wrong_domain`: User (kms-opa-test) denied `GetAttributes` on a key owned by kms-opa-other — domain boundary blocks even the least-privileged role + - `mode_enforcing_wrong_domain`: CryptoOfficer (kms-opa-other) denied `Get` on a kms-opa-test key in enforcing mode — domain isolation is not exclusive-mode-only + - `mode_exclusive_super_admin_cross_domain`: SuperAdmin allowed `Get` and `Destroy` across domain boundaries — proves only the designated top role bypasses `same_domain`; now 15 OPA vectors total +- Extend `setup_auth_server_for_opa` to provision 5 users with per-user `domain` field: `kms-opa-officer` (CryptoOfficer, kms-opa-test), `kms-opa-user` (User, kms-opa-test), `kms-opa-auditor` (Auditor, kms-opa-test), `kms-opa-domain-admin-other` (DomainAdmin, kms-opa-other), `kms-opa-other-officer` (CryptoOfficer, kms-opa-other); store JWTs in `KMS_TEST_OPA_AUDITOR_JWT`, `KMS_TEST_OPA_DOMAIN_ADMIN_OTHER_JWT` + +## Bug Fixes + +- `UserClaim` JWT deserialization: add `#[serde(default)]` to `aud` field so JWTs without an `aud` claim (e.g. from Cosmian auth server) are accepted instead of failing with "missing field `aud`" +- JWT middleware: fall back to `sub` claim when `email` is absent, enabling compatibility with Cosmian auth server JWTs that use `sub` for the username +- JWT middleware (`handle_jwt`): remove panicking `actix_identity::Identity::extract` call; the JWT bearer-token middleware reads directly from the `Authorization: Bearer` header — session cookie auth is handled by the dedicated `SessionAuth` middleware and must not be mixed into the OIDC JWT path +- OPA denied test servers: merge `exclusive_denied` and `enforcing_denied` into a single shared `ONCE_VECTOR_OPA_DENIED` singleton to avoid concurrent macOS Keychain PKCS#12 loading failures (`OSStatus -26276`) when both servers start in parallel +- OPA denied test servers: set opa-mode-specific `sqlite_path`, `root_data_path`, and `socket_server_start = false` to prevent port/file conflicts between concurrent cert-auth test servers +- Auth server provisioning: use `drop()` instead of `let _ =` on HTTP responses to avoid `let_underscore_drop` Clippy lint +- Auth server provisioning: treat all HTTP errors on idempotent steps (realm/admin/userpass creation) as non-fatal, since re-running against a live in-memory auth server returns `500 UNIQUE constraint` instead of `409` +- Fix `create.rs` to stamp the created object with the creator's domain (from OPA user context) instead of hardcoded `""`, enabling non-owner domain-scoped role checks (Auditor, DomainAdmin) to work correctly +- Fix `kms.rego` operation names: all role-based op sets (`crypto_officer_ops`, `auditor_ops`, `user_ops`) now use lowercase snake_case names matching `KmipOperation::Display` output (e.g. `"get_attributes"` not `"GetAttributes"`) +- Fix `mode_exclusive_auditor_destroy_denied` manifest: wrong `assert_error_reason` was `Object_Not_Found`; Destroy uses a count guard returning `Item_Not_Found` when no object passes permission check +- Fix auth server provisioning: set `domain` field on each userpass record to the realm ID so the JWT `as_domain` claim is emitted and OPA `same_domain` checks work correctly +- Fix `enforce_create_permission`: honor `crypto_officer.users` and `default_username` for the `Create` right even when OPA is active, so KMS-native CryptoOfficers (e.g. split-key ceremony participants) can still create keys; OPA remains the authoritative gatekeeper for all other users +- OPA test provisioning: send plaintext passwords to the auth server's `create_userpass` endpoint (the server now computes the Argon2id hash itself) and drop the obsolete `argon2`/`sha2` dev-dependencies +- Add 5 unit tests for `handle_auth_verifier` (full pipeline: HTTP `Authorization` header → `AuthenticatedUser.domain` + `.roles`) and 5 unit tests for `handle_jwt` (full pipeline: Authorization header → `AuthenticatedUser.domain` + `.roles` + username resolution) in `crate/server/src/middlewares/`; these are the definitive proof that `domain` and `roles` survive the middleware pipeline without being dropped diff --git a/Cargo.toml b/Cargo.toml index 7487c894c7..c1e52c234e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -249,6 +249,7 @@ scratchstack-aws-signature = "=0.10" # Must stay 0.10 — 0.11 API redesign requ serde = "1.0" serde_ignored = "0.1" serde_json = "1.0" +argon2 = { version = "0.5", default-features = false } serde_yaml = "0.9" sha2 = { version = "0.10", default-features = false } sha3 = { version = "0.10", default-features = false } diff --git a/OPA-middleware.md b/OPA-middleware.md new file mode 100644 index 0000000000..d104289b1f --- /dev/null +++ b/OPA-middleware.md @@ -0,0 +1,642 @@ +# OPA RBAC Middleware — Context and Implementation Plan + +## Context + +### What is being built + +An OPA (Open Policy Agent) authorization layer for Cosmian KMS that evaluates RBAC rules +before (or alongside) the existing KMS permission system. + +KMS runs the OPA server as a sidecar. On every KMIP operation, KMS calls +`POST /v1/data/kms/allow` with a JSON input document. OPA evaluates `kms.rego` and returns +`{"result": true|false}`. + +Three modes: + +| Mode | Name | Semantics | +|------|------|-----------| +| 1 | `Disabled` | OPA is not called; existing KMS permission system runs unchanged | +| 2 | `Exclusive` | Only OPA decides; KMS permission system is skipped | +| 3 | `Enforcing` | OPA runs first; if true, KMS permission system also runs; both must allow | + +Fail-closed: if OPA is configured but unreachable, the request is denied. + +--- + +### Role model + +Five roles (defined in the authentication server's realm configuration, embedded in JWT): + +| Role | Access | +|------|--------| +| `SuperAdmin` | Unrestricted, cross-domain | +| `DomainAdmin` | Full control within their own domain | +| `CryptoOfficer` | Key lifecycle operations within their domain (FIPS 140-3 §7.4) | +| `Auditor` | Read-only metadata within their domain (NIST SP 800-53 AU-9) | +| `User` | Crypto-use only within their domain (encrypt/decrypt/sign/verify) | + +Roles are stored per-user per-realm in the authentication server's `userpass` table. +Multiple roles per user are allowed (RBAC union semantics in OPA — existential over the array). +An object owner always has full access regardless of role. + +--- + +### Architecture: A3-i (JWT carries `roles` claim) + +```text +User authenticates → Auth Server issues JWT + JWT contains: + "roles": ["CryptoOfficer"] ← RFC 9068 §2.2.3.1, RFC 7643 §4.1.2 + "as_domain": "acme.com" ← private claim (RFC 7519 §4.3) + +KMS receives request with JWT + KMS extracts: + sub → input.user + as_domain → input.user_domain + roles → input.roles[] + +KMS calls OPA: + POST /v1/data/kms/allow + { + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "operation": "Create", + "object_uid": "*", + "object_domain": "acme.com", + "is_owner": false + } + } + +OPA evaluates kms.rego → {"result": true} + +KMS applies mode logic: + Exclusive: allow = opa_result + Enforcing: allow = opa_result AND kms_result +``` + +Non-JWT auth (mTLS, API token): KMS sends `"roles": []` → all role-based rules are false → fail-closed. + +--- + +### Domain model + +`user_domain` comes from the `as_domain` JWT private claim — not inferred from the `sub` string. +This decouples username format from domain assignment entirely. + +`object_domain` is stamped at object creation time from the creator's `user_domain` and stored +as a new `domain` column on the `objects` table. It is immutable after creation. + +For object-less operations (e.g. `Create`): `object_domain = user_domain` at permission-check time. + +Existing objects (before this feature) get `domain = ''`, which only `SuperAdmin` can access +via domain-scoped roles (the `same_domain` rule fails; owner rule still works). + +--- + +### Key files + +**Authentication server (`Cosmian/authentication`, branch `develop`):** + +| File | Change | +|------|--------| +| `client/src/models/client_claims.rs` | New `AuthorizationClaims` struct (`roles`); `as_domain` in `AuthPrivateClaims` | +| `client/src/models/base.rs` | `roles: Vec` and `domain: Option` on `UserPass` | +| `server/src/session/jwt.rs` | `issue_token()` gains `roles` and `domain` params | +| `server/src/server/endpoints/client_endpoints.rs` | Login fetches userpass; passes roles+domain to token | +| `server/src/database/impls/{sqlite,postgres,mysql}.rs` | Schema + CRUD for new columns | +| `server/documentation/openapi.yaml` | `UserPass` schema update | + +**KMS (`Cosmian/kms`, branch `rbac_rego`):** + +| File | Change | +|------|--------| +| `test_data/opa/kms.rego` | `input.roles[_]` existential; `count(input.roles) == 0` for no-role | +| `crate/interfaces/src/stores/object_with_metadata.rs` | Add `domain: String` field | +| `crate/server_database/src/stores/sql/query.sql` | `domain` column on `objects` | +| `crate/server_database/src/stores/sql/query_mysql.sql` | Same for MySQL | +| `crate/server_database/src/stores/sql/pgsql.rs` | Migration block for `domain` | +| `crate/server_database/src/stores/sql/{sqlite,mysql}.rs` | CRUD update | +| `crate/server/src/core/retrieve_object_utils.rs` | OPA call in `user_has_permission()` | +| `crate/server/src/core/kms/mod.rs` | `opa_client: Option>` | +| `crate/server/src/config/params/server_params.rs` | `opa_params: Option` | +| `crate/server/src/config/command_line/clap_config.rs` | `--opa-url`, `--opa-mode` | + +--- + +### OPA input document + +```json +{ + "input": { + "user": "", + "user_domain": "", + "roles": ["", ...], + "is_super_admin": false, + "operation": "", + "object_uid": "", + "object_domain": "", + "is_owner": true + } +} + +| Field | Source | Default | +|-------|--------|---------| +| `user` | JWT `sub` / TLS CN / API-token id | — | +| `user_domain` | JWT `as_domain` private claim | `""` | +| `roles` | JWT `roles` public claim (RFC 9068) | `[]` | +| `is_super_admin` | `kms.is_super_admin(user).await?` (SA3) | `false` | +| `operation` | KMIP operation tag | — | +| `object_uid` | Target object UID | `"*"` for object-less ops | +| `object_domain` | `objects.domain` column | `user_domain` for object-less ops | +| `is_owner` | `user == owm.owner()` | `false` | +``` + +--- + +### Normative references + +| Standard | Used for | +|----------|----------| +| RFC 7519 | JWT registered claims (`sub`, `exp`, etc.) | +| RFC 9068 §2.2.3.1 | `roles` as a standard JWT authorization claim | +| RFC 7643 §4.1.2 | SCIM User schema — `roles` attribute definition | +| FIPS 140-3 §7.4 | CryptoOfficer and User as mandatory module roles | +| NIST SP 800-57 Part 2 §4.3 | Key management role definitions | +| ANSI/INCITS 359-2004 §4.2 | RBAC hierarchy and separation of duties | +| NIST SP 800-53 Rev 5 AC-5, AC-6, AU-9 | Least privilege, SoD, audit protection | + +### KMIP role taxonomy (verified against local spec files) + +> **Verified against `kmip/v2.1/kmip-spec-v2.1-os.html` and `kmip/v3.0/kmip-spec-v3.0-csd01.html`.** + +KMIP defines **two** concepts named "role" — neither of which is a user authorization role: + +#### 1. Endpoint Role (KMIP 2.1 §11.19, Table 451–452) + +Used exclusively by the `Set Endpoint Role` operation (§6.1.54 / §6.2.5), which +swaps client/server roles on a bidirectional communication channel. + +| Value | Description | +|-------|-------------| +| `Client` | The endpoint that sends requests and receives responses | +| `Server` | The endpoint that receives requests and sends responses | + +**Not a user authorization concept.** Only relevant for KMIP bidirectional channel setup. + +#### 2. Key Role Type (KMIP 2.1 §11.26, Table 462) + +A cryptographic key classification attribute — describes the *purpose* of a key, +not who may use it. Values (KMIP 2.1): + +| Name | Value | Meaning | +|------|-------|---------| +| BDK | 0x00000001 | Base Derivation Key | +| CVK | 0x00000002 | Card Verification Key | +| DEK | 0x00000003 | Data Encryption Key | +| MKAC | 0x00000004 | Master Key — AC | +| MKSMC | 0x00000005 | Master Key — SMC | +| MKSMI | 0x00000006 | Master Key — SMI | +| MKDAC | 0x00000007 | Master Key — DAC | +| MKDN | 0x00000008 | Master Key — DN | +| MKCP | 0x00000009 | Master Key — CP | +| MKOTH | 0x0000000A | Master Key — Other | +| KEK | 0x0000000B | Key Encryption Key | +| MAC16609 | … | MAC key (ISO 16609) | + +**Not a user authorization concept.** This is a key metadata attribute, not an +identity/permission claim. + +#### Conclusion: KMIP does NOT define user authorization roles + +Neither KMIP 2.1 nor KMIP 3.0 defines concepts such as Administrator, Auditor, +or CryptoOfficer as user authorization roles. The terms "CryptoOfficer" and +"Auditor" do not appear anywhere in either specification outside of OASIS +administrative boilerplate. + +The role model in this project (`SuperAdmin`, `DomainAdmin`, `CryptoOfficer`, +`Auditor`, `User`) is **implementation-defined**, sourced from: + +- **FIPS 140-3 §7.4** — mandates a "Crypto Officer" role and a "User" role for + cryptographic module access control. Names and semantics are normative. +- **NIST SP 800-57 Part 2 §4.3** — defines "Key Management Officer", "Audit and + Compliance Officer", and "Key User" as organizational roles in a key management + infrastructure. +- **ANSI/INCITS 359-2004 §4.2** — RBAC model for hierarchical and constrained + role structures (basis for DomainAdmin hierarchy). + +These are the correct normative citations for the role names used in `kms.rego`. + +--- + +## Design decisions + +| # | Question | Decision | Rationale | +|---|----------|----------|-----------| +| S1 | Role storage in auth DB | JSON TEXT column on `userpass` | Minimal schema change; roles are only read at login, never filtered in SQL | +| R1 | JWT `roles` wire shape | `Vec` — plain string array | RFC 9068 provides no vocabulary; string array is the common implementation | +| M3 | Multi-role | Allowed; OPA uses `input.roles[_] == "X"` | Flexibility without ambiguity; union semantics via Rego existential | +| DB1 | DB storage | JSON column (not join table) | No SQL filtering by role; join table complexity unwarranted | +| P1 | Claims struct placement | New `AuthorizationClaims` struct (RFC 9068 §4.2) | `roles` is a public IANA-registered claim, not a private `as_` claim | +| N1 | Non-JWT auth → roles | `input.roles = []` | Fail-closed; Rego existential over empty set is false | +| E3 | `user_domain` source | Dedicated `as_domain` JWT private claim | Decouples username format from domain; `sub` may be email, CN, or UUID | +| OD1 | Object domain assignment | Stamped at creation from `user_domain`, immutable | Auditable, consistent; mirrors how `owner` is assigned | +| OB1 | Object domain storage | New `domain` column on `objects` table | First-class operational field; consistent with `owner` and `state` | +| D-B | `as_domain` claim placement | `AuthPrivateClaims` with `rename = "as_domain"` | Private deployment claim (RFC 7519 §4.3); not an IANA-registered public claim | +| SA3 | Ceremony super-admin | Fully decoupled from OPA; operates only inside native KMS gate | Two systems are independent; OPA handles JWT roles, native KMS handles ceremony SA | + +--- + +## Ceremony super-admin and OPA — separation of concerns + +The KMS ceremony super-admin (Shamir split-key) and OPA RBAC are **fully independent**: + +- `is_super_admin` is **not** included in the OPA input document. +- OPA has no visibility into the ceremony state. +- In Mode 1 (Disabled): ceremony super-admin operates as before (early return in native KMS check). +- In Mode 2 (Exclusive): ceremony has no effect — OPA is the sole decision maker. +- In Mode 3 (Enforcing): ceremony super-admin takes effect inside Gate 2 (native KMS), only after OPA has already allowed. + +The JWT `SuperAdmin` role (assigned by auth admin) and the ceremony super-admin (Shamir activation) are distinct concepts with different trust anchors and different enforcement paths. + +--- + +## Implementation plan + +### Phase 1 — Auth server: data model + +**Step 1** — `client/src/models/client_claims.rs` + +Add `AuthorizationClaims` (RFC 9068 §2.2.3.1 public claims, RFC 7643 §4.1.2): + +```rust +/// RFC 9068 §2.2.3.1 — Authorization claims (IANA-registered via RFC 7643 §4.1.2). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AuthorizationClaims { + /// `roles` — roles assigned to the subject. + /// RFC 7643 §4.1.2; registered in IANA JWT Claims registry by RFC 9068 §7.2.1.1. + #[serde(skip_serializing_if = "Option::is_none")] + pub roles: Option>, +} +``` + +Add `domain` to `AuthPrivateClaims`: + +```rust +/// Domain the subject belongs to, used for domain-scoped RBAC. +/// Private claim (RFC 7519 §4.3) — not an IANA-registered name. +#[serde(rename = "as_domain", skip_serializing_if = "Option::is_none")] +pub domain: Option, +``` + +Add to `ClientClaims`: + +```rust +/// RFC 9068 §2.2.3.1 — authorization attributes (roles, groups, entitlements). +#[serde(flatten)] +pub authorization: AuthorizationClaims, +``` + +**Step 2** — `client/src/models/base.rs` + +Add to `UserPass`: + +```rust +/// Roles assigned to this user in this realm. +/// Serialised as JSON array in the DB column `roles`. +pub roles: Vec, + +/// Domain the user belongs to (e.g. "acme.com"). +/// Emitted as the `as_domain` JWT private claim. +pub domain: Option, +``` + +--- + +### Phase 2 — Auth server: database layer (all 3 backends) + +**Step 3** — Schema change in each backend: + +```sql +-- Add to CREATE TABLE userpass: +roles TEXT NOT NULL DEFAULT '[]', +domain TEXT +``` + +Migration queries (run at startup, check-then-ALTER pattern): + +```sql +-- add-column-roles +ALTER TABLE userpass ADD COLUMN roles TEXT NOT NULL DEFAULT '[]'; +-- add-column-domain +ALTER TABLE userpass ADD COLUMN domain TEXT; +``` + +**Step 4** — CRUD update in all 3 backends: + +- `create_userpass`: INSERT includes `roles` (JSON-serialised), `domain` +- `get_userpass`: SELECT reads `roles` (deserialise → `Vec`), `domain` +- `update_userpass`: UPDATE includes both +- `list_*`: SELECT includes both + +Serialisation: + +- write: `serde_json::to_string(&userpass.roles)?` +- read: `serde_json::from_str::>(&row_roles).unwrap_or_default()` + +--- + +### Phase 3 — Auth server: token issuance + +**Step 5** — `server/src/session/jwt.rs` + +New signature: + +```rust +pub fn issue_token( + subject: &str, + auth_scheme: AuthScheme, + realm_id: &str, + public_key_pem: Option, + roles: Vec, // NEW — from UserPass.roles + domain: Option, // NEW — from UserPass.domain + algorithm: Algorithm, + encoding_key: EncodingKey, + expiration_seconds: i64, +) -> Result +``` + +Inside: `claims.authorization.roles = Some(roles)` and `claims.private.domain = domain`. + +**Step 6** — `server/src/server/endpoints/client_endpoints.rs` + +In `login()`, after TOTP check, before `issue_token()`: + +```rust +let userpass = database + .get_userpass(&realm.id, &authenticated_client.username) + .await?; +let (roles, domain) = userpass + .map(|u| (u.roles, u.domain)) + .unwrap_or_default(); +// Non-userpass schemes (mTLS) return None → roles=[], domain=None → N1 satisfied +``` + +--- + +### Phase 4 — Auth server: API surface + +**Step 7** — `server/src/server/endpoints/realms_endpoints.rs` + +No handler logic change needed — `roles` and `domain` are carried by the `UserPass` struct +which is already deserialized from the request body and passed to `database.create_userpass()`. + +**Step 8** — `server/documentation/openapi.yaml` + +Add to `UserPass` schema: + +```yaml +roles: + type: array + items: + type: string + description: "Roles assigned to this user (RFC 9068 §2.2.3.1)" + example: ["CryptoOfficer"] +domain: + type: string + nullable: true + description: "Domain the user belongs to, emitted as as_domain JWT claim" + example: "acme.com" +``` + +--- + +### Phase 5 — KMS: Rego policy update + +**Step 9** — `test_data/opa/kms.rego` + +- `input.role` → removed entirely +- All role checks: `input.role == "X"` → `input.roles[_] == "X"` (Rego existential) +- `reason := "no_role"`: check `count(input.roles) == 0; not input.is_owner` +- Header: document `input.roles` as array from JWT, `input.user_domain` from `as_domain` + +--- + +### Phase 6 — KMS: object domain + +**Step 10** — `crate/interfaces/src/stores/object_with_metadata.rs` + +```rust +pub struct ObjectWithMetadata { + id: String, + object: Object, + owner: String, + state: State, + attributes: Attributes, + domain: String, // NEW — stamped at creation, empty string for legacy objects +} +``` + +Add `domain()` getter; update `new()` to require `domain: String`; update `Display`. + +**Step 11** — SQL schema (`query.sql`, `query_mysql.sql`) + +```sql +-- In CREATE TABLE objects: +domain VARCHAR(255) NOT NULL DEFAULT '', +``` + +Migration: + +```sql +-- add-column-domain +ALTER TABLE objects ADD COLUMN domain VARCHAR(255) NOT NULL DEFAULT ''; +``` + +**Step 12** — DB backend CRUD (sqlite.rs, pgsql.rs, mysql.rs) + +- `ObjectsStore::create()` trait: add `domain: &str` parameter +- INSERT: include `domain` +- SELECT: include `domain`, populate `ObjectWithMetadata::domain` + +--- + +### Phase 7 — KMS: OPA input construction + +**Step 13** — `OpaInput` struct (new module, e.g. `crate/server/src/core/opa/input.rs`): + +```rust +#[derive(Serialize)] +pub struct OpaInput { + pub user: String, + pub user_domain: String, // from claims.private.domain (as_domain), or "" + pub roles: Vec, // from claims.authorization.roles, or [] + pub is_super_admin: bool, // SA3: from kms.is_super_admin(user) + pub operation: String, + pub object_uid: String, + pub object_domain: String, // from ObjectWithMetadata.domain + pub is_owner: bool, +} +``` + +**Step 14** — `build_input()` in the permission layer: + +- JWT present: extract `claims.authorization.roles` → `Vec`, `claims.private.domain` → `String` +- JWT absent (mTLS/API-token): `roles = vec![]`, `user_domain = ""` +- Object-less ops (Create, Locate, etc.): `object_domain = user_domain` +- Object-bearing ops: `object_domain = owm.domain()` +- SA3: call `kms.is_super_admin(user).await?` → `is_super_admin: bool` + +**Step 15** — Object creation path: + +When `Create` is dispatched, `user_domain` is available from the request context. +Pass it into `database.create(uid, owner, object, attributes, tags, user_domain)`. + +--- + +### Phase 8 — KMS: OPA client + configuration + +**Step 16** — New types: + +```rust +pub enum OpaMode { Disabled, Exclusive, Enforcing } + +pub struct OpaParams { + pub url: String, // e.g. "http://localhost:8181" + pub mode: OpaMode, +} +``` + +**Step 17** — `OpaClient` (reqwest, fail-closed): + +```rust +impl OpaClient { + /// Returns Ok(true) if OPA allows, Ok(false) if denied, Err if unreachable. + /// Callers treat Err as deny (fail-closed). + pub async fn query(&self, input: &OpaInput) -> Result; +} +``` + +**Step 18** — Extend `ServerParams` and KMS struct: + +- `server_params.rs`: `pub opa_params: Option` +- `kms/mod.rs`: `pub(crate) opa_client: Option>` + +**Step 19** — `clap_config.rs`: + +```text +--opa-url OPA sidecar base URL (enables OPA integration) +--opa-mode "exclusive" or "enforcing" [default: "enforcing"] +``` + +**Step 20** — `crate/server/src/core/retrieve_object_utils.rs` + +In `user_has_permission()` — SA3 integration: + +```rust +// Mode 0 (Disabled) — existing flow unchanged: +// line 225: if kms.is_super_admin(user).await? { return Ok(true); } +// ... normal HSM + DB checks ... + +// Mode 2/3 (Exclusive/Enforcing) — OPA is the sole gate: +if kms.opa_client.is_some() { + // Skip the is_super_admin() early return — SA3 feeds it into OPA input instead + let input = build_input(kms, user, owm, operation_type).await?; + // ↑ build_input calls kms.is_super_admin(user) → sets input.is_super_admin + let opa_ok = kms.opa_client.as_ref().unwrap() + .query(&input).await.unwrap_or(false); // fail-closed + match kms.params.opa_mode { + OpaMode::Exclusive => return Ok(opa_ok), + OpaMode::Enforcing => { + if !opa_ok { return Ok(false) } + // fall through to HSM admin + DB grant checks + } + OpaMode::Disabled => unreachable!(), + } +} else { + // Mode 0 — existing super-admin bypass + normal logic + if kms.is_super_admin(user).await? { + warn!("SUPER_ADMIN_ACCESS: ..."); + return Ok(true); + } +} +// ... HSM admin check, DB permission check ... +``` + +--- + +## Verification + +```bash +# 1. Auth server tests +cd /Users/manu/Cosmian/github/authentication +cargo test --workspace + +# 2. KMS tests +cd /Users/manu/Cosmian/core/cli_alt3/kms +cargo test-non-fips + +# 3. OPA smoke test — should return {"result":true} +docker compose up -d opa +curl -s -X POST http://localhost:8181/v1/data/kms/allow \ + -H 'Content-Type: application/json' \ + -d '{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "is_super_admin": false, + "operation": "Create", + "object_uid": "*", + "object_domain": "acme.com", + "is_owner": false + } + }' + +# 4. Same with empty roles — should return {"result":false} +curl -s -X POST http://localhost:8181/v1/data/kms/allow \ + -H 'Content-Type: application/json' \ + -d '{ + "input": { + "user": "anon", + "user_domain": "", + "roles": [], + "is_super_admin": false, + "operation": "Create", + "object_uid": "*", + "object_domain": "", + "is_owner": false + } + }' + +# 5. SA3 — ceremony super-admin with no JWT roles — should return {"result":true} +curl -s -X POST http://localhost:8181/v1/data/kms/allow \ + -H 'Content-Type: application/json' \ + -d '{ + "input": { + "user": "custodian@acme.com", + "user_domain": "", + "roles": [], + "is_super_admin": true, + "operation": "Destroy", + "object_uid": "key-123", + "object_domain": "acme.com", + "is_owner": false + } + }' + +# 6. Lint +cargo clippy-all # KMS +cargo clippy --workspace --all-targets -- -D warnings # auth server +``` + +--- + +## Scope exclusions + +- **Admin realm**: the `_` admin realm does not participate in KMS RBAC. Admins manage realms/users; they are not KMS crypto operators. +- **Redis-findex backend**: domain column follows the same OB1 pattern but is tracked as a separate task (Redis store has a different object representation). +- **Wizard / TOML templates**: `--opa-url` / `--opa-mode` are CLI flags only in this phase; wizard integration is deferred. +- **WASM bindings**: client-side only, no OPA involvement. +- **`privileged_users`**: this config field is not consulted in OPA modes 2 or 3. SuperAdmin role in OPA is the equivalent. diff --git a/crate/clients/clap/src/actions/symmetric/keys/create_split_key.rs b/crate/clients/clap/src/actions/symmetric/keys/create_split_key.rs index a0f050c9b1..372049dc54 100644 --- a/crate/clients/clap/src/actions/symmetric/keys/create_split_key.rs +++ b/crate/clients/clap/src/actions/symmetric/keys/create_split_key.rs @@ -2,18 +2,14 @@ use clap::Parser; use cosmian_kms_client::{ KmsClient, kmip_2_1::{ - kmip_attributes::Attribute, kmip_objects, - kmip_operations::{CreateSplitKey, DeleteAttribute, SetAttribute}, - kmip_types::{ - AttributeReference, SplitKeyMethod, UniqueIdentifier, VendorAttribute, - VendorAttributeReference, VendorAttributeValue, - }, + kmip_operations::CreateSplitKey, + kmip_types::{SplitKeyMethod, UniqueIdentifier}, }, }; use crate::{ - actions::{console, shared::VENDOR_ATTR_CO_CEREMONY}, + actions::console, error::result::{KmsCliResult, KmsCliResultHelper}, }; @@ -29,16 +25,6 @@ use crate::{ /// different Crypto Officer candidate (round-robin), enforcing dual control: /// the future active CO must obtain GET grants from every other CO before activating. /// -/// # Two ceremony split commands -/// -/// This command (`ckms sym keys create-split-key --ceremony`) is the **bring-your-own-key** -/// path: the key already exists and the caller wants to turn it into a ceremony key. -/// The guided alternative (`ckms access-rights crypto-officer create-split-key`) creates -/// the AES-256 source key for you, stamps it, splits it, and cleans up the source key on -/// failure — suitable for operators who want a single-step ceremony provisioning command. -/// Both commands stamp the same attribute and call the same server-side `CreateSplitKey` -/// operation; they differ only in who creates and owns the source key. -/// /// Example: /// `ckms sym keys create-split-key --key-id --total-parts 3` /// `ckms sym keys create-split-key --key-id --ceremony` @@ -97,23 +83,23 @@ impl CreateSplitKeyAction { /// When `--ceremony` is set, the key is first stamped with the /// `x-cosmian-crypto-officer-ceremony` vendor attribute so the server /// distributes shares to different CO candidates instead of assigning - /// them all to the caller. If `CreateSplitKey` fails after the attribute - /// was stamped, a best-effort `DeleteAttribute` is issued to leave the - /// caller's key in its original state. Unlike the guided - /// `access-rights crypto-officer create-split-key` command, the source key - /// is **not** destroyed on failure — it existed before this command ran. + /// them all to the caller. /// /// # Errors /// /// Returns an error if the server request fails. pub async fn run(&self, kms_rest_client: KmsClient) -> KmsCliResult<()> { - let vendor_id = kms_rest_client.config.vendor_id.as_str(); - // If --ceremony, stamp the vendor attribute on the source key first. - let ceremony_attr_stamped = if self.ceremony { + if self.ceremony { + use cosmian_kms_client::kmip_2_1::{ + kmip_attributes::Attribute, + kmip_operations::SetAttribute, + kmip_types::{VendorAttribute, VendorAttributeValue}, + }; + const VENDOR_ID_COSMIAN: &str = "cosmian"; let attr = Attribute::VendorAttribute(VendorAttribute { - vendor_identification: vendor_id.to_owned(), - attribute_name: VENDOR_ATTR_CO_CEREMONY.to_owned(), + vendor_identification: VENDOR_ID_COSMIAN.to_owned(), + attribute_name: "x-cosmian-crypto-officer-ceremony".to_owned(), attribute_value: VendorAttributeValue::TextString("true".to_owned()), }); kms_rest_client @@ -123,10 +109,7 @@ impl CreateSplitKeyAction { }) .await .with_context(|| "failed to set ceremony attribute on key before splitting")?; - true - } else { - false - }; + } let request = CreateSplitKey { object_type: kmip_objects::ObjectType::SymmetricKey, @@ -138,46 +121,11 @@ impl CreateSplitKeyAction { protection_storage_masks: None, }; - let split_result = kms_rest_client + let response = kms_rest_client .create_split_key(request) .await - .with_context(|| "failed to create split key shares"); + .with_context(|| "failed to create split key shares")?; - let response = match split_result { - Ok(r) => r, - Err(e) => { - // Compensating delete: if we stamped the ceremony attribute and then the split - // failed, remove the attribute so the key is left in its original state. - // The source key itself is NOT destroyed — it existed before this command. - if ceremony_attr_stamped { - let attr_ref = AttributeReference::Vendor(VendorAttributeReference { - vendor_identification: vendor_id.to_owned(), - attribute_name: VENDOR_ATTR_CO_CEREMONY.to_owned(), - }); - if let Err(del_err) = kms_rest_client - .delete_attribute(DeleteAttribute { - unique_identifier: Some(UniqueIdentifier::TextString( - self.key_id.clone(), - )), - current_attribute: None, - attribute_references: Some(vec![attr_ref]), - }) - .await - { - eprintln!( - "WARNING: CreateSplitKey failed and the compensating removal of the \ - ceremony attribute on key '{}' also failed ({del_err}). \ - The key retains the `{VENDOR_ATTR_CO_CEREMONY}` attribute; \ - remove it manually with: \ - ckms attributes delete --id {} --vendor-id {vendor_id} \ - --attr-name {VENDOR_ATTR_CO_CEREMONY}", - self.key_id, self.key_id, - ); - } - } - return Err(e); - } - }; let share_count = response.unique_identifier.len(); let mut stdout = console::Stdout::new(&format!( "Key {} successfully split into {} share(s) (XOR n-of-n){}.", diff --git a/crate/interfaces/src/hsm/hsm_store.rs b/crate/interfaces/src/hsm/hsm_store.rs index c076cd26b7..6d7c92edfc 100644 --- a/crate/interfaces/src/hsm/hsm_store.rs +++ b/crate/interfaces/src/hsm/hsm_store.rs @@ -84,6 +84,7 @@ impl ObjectsStore for HsmStore { object: &Object, attributes: &Attributes, _tags: &HashSet, + _domain: &str, ) -> InterfaceResult { if !self.is_admin(owner) { return Err(InterfaceError::Unauthorized( @@ -187,6 +188,7 @@ impl ObjectsStore for HsmStore { self.owner_name().to_owned(), State::Active, attrs, + String::new(), ))) } } @@ -1184,6 +1186,7 @@ fn to_object_with_metadata( user.to_owned(), State::Active, attributes, + String::new(), )) } KeyMaterial::RsaPrivateKey(km) => { @@ -1253,6 +1256,7 @@ fn to_object_with_metadata( user.to_owned(), State::Active, attributes, + String::new(), )) } KeyMaterial::RsaPublicKey(km) => { @@ -1308,6 +1312,7 @@ fn to_object_with_metadata( user.to_owned(), State::Active, attributes, + String::new(), )) } } diff --git a/crate/interfaces/src/stores/object_with_metadata.rs b/crate/interfaces/src/stores/object_with_metadata.rs index 1fb667d5fe..b9a8ae6c5b 100644 --- a/crate/interfaces/src/stores/object_with_metadata.rs +++ b/crate/interfaces/src/stores/object_with_metadata.rs @@ -25,6 +25,9 @@ pub struct ObjectWithMetadata { owner: UserId, state: State, attributes: Attributes, + /// The domain this object belongs to (for OPA domain-scoped RBAC). + /// Stamped at creation from the creator's `user_domain`; empty string for legacy objects. + domain: String, } impl ObjectWithMetadata { @@ -35,6 +38,7 @@ impl ObjectWithMetadata { owner: impl Into, state: State, attributes: Attributes, + domain: String, ) -> Self { Self { id, @@ -42,6 +46,7 @@ impl ObjectWithMetadata { owner: owner.into(), state, attributes, + domain, } } @@ -93,6 +98,11 @@ impl ObjectWithMetadata { &mut self.attributes } + #[must_use] + pub fn domain(&self) -> &str { + &self.domain + } + /// Resolve the effective cryptographic algorithm for this managed object. /// /// Checks the key block's algorithm first, then falls back to the object's @@ -262,8 +272,8 @@ impl Display for ObjectWithMetadata { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!( f, - "ObjectWithMetadata {{ id: {}, object: {}, owner: {}, state: {}, attributes: {} }}", - self.id, self.object, self.owner, self.state, self.attributes + "ObjectWithMetadata {{ id: {}, object: {}, owner: {}, state: {}, attributes: {}, domain: {} }}", + self.id, self.object, self.owner, self.state, self.attributes, self.domain ) } } @@ -310,6 +320,7 @@ mod tests { "owner".to_owned(), State::Active, ext_attrs, + String::new(), ) } @@ -414,6 +425,7 @@ mod tests { "owner".to_owned(), State::Active, Attributes::default(), // no external attrs + String::new(), ); assert!(!owm.is_within_process_window()); Ok(()) @@ -439,6 +451,7 @@ mod tests { protect_stop_date: Some(now - Duration::hours(1)), ..Default::default() }, + String::new(), ); assert!( owm.is_within_process_window(), diff --git a/crate/interfaces/src/stores/objects_store.rs b/crate/interfaces/src/stores/objects_store.rs index ddfdd4d30a..97540a4663 100644 --- a/crate/interfaces/src/stores/objects_store.rs +++ b/crate/interfaces/src/stores/objects_store.rs @@ -62,6 +62,7 @@ pub trait ObjectsStore { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> InterfaceResult; /// Retrieve an object from the database. diff --git a/crate/interfaces/src/user_id.rs b/crate/interfaces/src/user_id.rs index 751412a803..592401c003 100644 --- a/crate/interfaces/src/user_id.rs +++ b/crate/interfaces/src/user_id.rs @@ -23,11 +23,19 @@ use serde::{Deserialize, Serialize}; pub struct UserId(String); impl UserId { - /// Try to wrap a string as a `UserId`, rejecting empty strings. + /// Wrap any `Into` value as a `UserId`. /// - /// Use this whenever the string originates from user input or any - /// untrusted source. For string literals in tests, `UserId::from("…")` - /// is sufficient — the `From` impl checks for emptiness in debug builds. + /// # Panics + /// Panics in debug mode if the string is empty. Use [`try_new`](Self::try_new) + /// for validated construction. + #[must_use] + pub fn new(s: impl Into) -> Self { + let s = s.into(); + debug_assert!(!s.is_empty(), "UserId must not be empty"); + Self(s) + } + + /// Try to wrap a string as a `UserId`, rejecting empty strings. /// /// # Errors /// Returns an error if the string is empty. diff --git a/crate/server/kms_template.toml b/crate/server/kms_template.toml index a7e4259764..226daa41c8 100644 --- a/crate/server/kms_template.toml +++ b/crate/server/kms_template.toml @@ -412,66 +412,5 @@ vault_token_cache_ttl_secs = 0 # When `true`, users listed in `crypto_officer_users` are candidates only — # the role is inactive until a KMIP `JoinSplitKey` with all shares tagged # `x-cosmian-crypto-officer-ceremony` completes +# (NIST SP 800-57 Part 2 Rev 1 §4.6 split knowledge, XOR n-of-n). crypto_officer_require_ceremony = false - -# Users with the Crypto Officer role (ISO/IEC 19790 "Crypto Officer" / PKCS#11 `CKU_SO`). -# -# May manage key lifecycle (create, import, certify, rekey, activate, revoke, destroy) -# and access raw key material (get, export — "key output" per ISO/IEC 19790 §7.4.3). -# When active, gains ownership bypass on all Managed Objects. -# When set, only listed users (plus those explicitly granted the `Create` right) can -# create and import objects. -# crypto_officer_users = ["alice@example.com", "bob@example.com"] - -# Hex-encoded 32-byte secret for ceremony record encryption. -# -# Required when any role has `require_ceremony = true`. -# All ceremony activation records are AES-256-GCM encrypted with keys -# derived from this secret, preventing forgery via direct database writes -# and protecting participant identities at rest. -# -# Generate with: `openssl rand -hex 32` -# ceremony_secret = "" - -# UID of a KMS symmetric key to use as the ceremony record sealing key. -# -# When set, key material is fetched from the KMS object store after database -# initialization and used in place of `ceremony_secret`. This enables: -# - Key rotation via standard KMIP `ReKey` / `Rotate` operations. -# - HSM-backed sealing when the referenced key is HSM-resident. -# - Audit trail: each retrieval of the ceremony key is logged. -# -# If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. -# -# **Bootstrap constraint**: the ceremony sealing key must be created before -# enabling `crypto_officer_require_ceremony = true`. Create it while the server -# is in config-only CO mode (no ceremony required), then enable ceremony mode: -# -# ```bash -# # 1. Start server with require_ceremony = false -# # 2. Create the sealing key: -# ckms sym keys create --id ceremony-seal-2026 --number-of-bits 256 -# # 3. Set ceremony_key_id = "ceremony-seal-2026" in kms.toml -# # 4. Enable require_ceremony = true and restart -# ``` -# ceremony_key_id = "" - -# UID of a KMS symmetric key to use for AES-KW (RFC 5649) wrapping of split-key shares. -# -# When set, `CreateSplitKey` encrypts each share's raw bytes with this key (AES-128/192/256-KWP) -# before storing in the database. `JoinSplitKey` automatically detects the -# `x-cosmian-share-wrapping-key` vendor attribute on each share and unwraps the bytes before -# XOR reconstruction. -# -# The wrapping key must already exist in the KMS object store and must be an AES symmetric key. -# When the KMS is HSM-backed, this key can be HSM-resident, providing hardware boundary -# protection equivalent to purpose-built HSM split-key solutions. -# -# Generate a suitable key before enabling ceremony mode: -# ```bash -# ckms sym keys create --id ceremony-wrap-2026 --number-of-bits 256 -# ``` -# -# Rotate by creating a new key, updating this value, and re-running the ceremony -# (existing wrapped shares require the original key; re-ceremony is mandatory on rotation). -# ceremony_wrapping_key_id = "ceremony-wrap-key" diff --git a/crate/server/src/config/command_line/auth_verifier_config.rs b/crate/server/src/config/command_line/auth_verifier_config.rs index 53ac764653..08d0bebaf7 100644 --- a/crate/server/src/config/command_line/auth_verifier_config.rs +++ b/crate/server/src/config/command_line/auth_verifier_config.rs @@ -1,5 +1,36 @@ use clap::Args; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +/// Deserialize `auth_verifier_realm` accepting both a single string and a list. +/// +/// ```toml +/// auth_verifier_realm = "acme.com" # still works +/// auth_verifier_realm = ["acme.com", "partner.com"] # new multi-realm form +/// ``` +fn deserialize_realm_list<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrList { + One(String), + Many(Vec), + } + + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrList::One(s)) if s.is_empty() => { + Err(de::Error::custom("auth_verifier_realm must not be empty")) + } + Some(StringOrList::One(s)) => Ok(Some(vec![s])), + Some(StringOrList::Many(v)) if v.is_empty() => Err(de::Error::custom( + "auth_verifier_realm list must not be empty", + )), + Some(StringOrList::Many(v)) => Ok(Some(v)), + } +} /// Configuration for the Auth Verifier server (server-side). /// @@ -35,14 +66,29 @@ pub struct AuthVerifierConfig { #[clap(long, env = "KMS_AUTH_VERIFIER_JWKS_URI", verbatim_doc_comment)] pub auth_verifier_jwks_uri: Option, - /// Realm to authenticate the Web UI against on the Auth Verifier server. + /// Realm(s) to authenticate the Web UI against on the Auth Verifier server. /// /// Required only to enable the Web UI login form for the Auth Verifier /// server (`POST /ui/login_as`); bearer-token validation of already-issued tokens /// does not need a realm. When unset, the UI falls back to any other configured /// authentication method (OIDC/JWT or client certificate). + /// + /// Accepts a single realm name or a list: + /// + /// ```toml + /// auth_verifier_realm = "acme.com" # single realm + /// auth_verifier_realm = ["acme.com", "partner.com"] # multi-realm + /// ``` + /// + /// When multiple realms are configured the Web UI shows a realm selector before + /// the username/password form. #[clap(long, env = "KMS_AUTH_VERIFIER_REALM", verbatim_doc_comment)] - pub auth_verifier_realm: Option, + #[serde( + default, + deserialize_with = "deserialize_realm_list", + skip_serializing_if = "Option::is_none" + )] + pub auth_verifier_realm: Option>, /// Accept invalid or self-signed TLS certificates when fetching the JWKS. /// @@ -64,11 +110,30 @@ impl AuthVerifierConfig { } /// Returns `true` if the Web UI login form for the Auth Verifier server - /// should be enabled, i.e. both `auth_verifier_url` and `auth_verifier_realm` + /// should be enabled, i.e. both `auth_verifier_url` and at least one realm /// are configured. #[must_use] - pub const fn ui_login_enabled(&self) -> bool { - self.auth_verifier_url.is_some() && self.auth_verifier_realm.is_some() + pub fn ui_login_enabled(&self) -> bool { + self.auth_verifier_url.is_some() + && self + .auth_verifier_realm + .as_ref() + .is_some_and(|v| !v.is_empty()) + } + + /// Returns the list of configured realms, or an empty slice when none are set. + #[must_use] + pub fn realms(&self) -> &[String] { + self.auth_verifier_realm.as_deref().unwrap_or(&[]) + } + + /// Returns the first configured realm, used as the default when the UI does + /// not specify one explicitly. + #[must_use] + pub fn primary_realm(&self) -> Option<&str> { + self.auth_verifier_realm + .as_ref() + .and_then(|v| v.first().map(String::as_str)) } /// Returns the effective JWKS URI: @@ -147,23 +212,63 @@ mod tests { cfg.auth_verifier_url = Some("https://auth.example.com".to_owned()); assert!(!cfg.ui_login_enabled()); - cfg.auth_verifier_realm = Some("kms".to_owned()); + // Single realm via vec + cfg.auth_verifier_realm = Some(vec!["kms".to_owned()]); + assert!(cfg.ui_login_enabled()); + assert_eq!(cfg.realms(), &["kms"]); + assert_eq!(cfg.primary_realm(), Some("kms")); + + // Multiple realms + cfg.auth_verifier_realm = Some(vec!["acme.com".to_owned(), "partner.com".to_owned()]); assert!(cfg.ui_login_enabled()); + assert_eq!(cfg.realms(), &["acme.com", "partner.com"]); + assert_eq!(cfg.primary_realm(), Some("acme.com")); + } + + #[test] + #[allow(clippy::panic_in_result_fn)] + fn test_realm_deserializes_single_string() -> Result<(), Box> { + let toml = r#" + auth_verifier_url = "https://auth.example.com" + auth_verifier_realm = "acme.com" + "#; + let cfg: AuthVerifierConfig = toml::from_str(toml)?; + assert_eq!(cfg.realms(), &["acme.com"]); + assert!(cfg.ui_login_enabled()); + Ok(()) } - /// Verify that the `auth_verifier.toml` test config parses correctly and + #[test] + #[allow(clippy::panic_in_result_fn)] + fn test_realm_deserializes_list() -> Result<(), Box> { + let toml = r#" + auth_verifier_url = "https://auth.example.com" + auth_verifier_realm = ["acme.com", "partner.com"] + "#; + let cfg: AuthVerifierConfig = toml::from_str(toml)?; + assert_eq!(cfg.realms(), &["acme.com", "partner.com"]); + assert_eq!(cfg.primary_realm(), Some("acme.com")); + assert!(cfg.ui_login_enabled()); + Ok(()) + } + + /// Verify that the canonical `[auth_verifier]` section parses correctly and /// enables both bearer-token validation and the Web UI login form. + /// + /// The TOML is inlined here to keep the test self-contained; it mirrors + /// `test_data/configs/server/auth/auth_verifier.toml` which is in a submodule + /// not checked out during unit-test CI runs. #[test] #[allow(clippy::panic_in_result_fn)] fn test_auth_verifier_toml_config_parses() -> Result<(), Box> { - let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../test_data/configs/server/auth/verifier.toml"); - let toml_content = std::fs::read_to_string(&config_path) - .map_err(|e| format!("failed to read {}: {e}", config_path.display()))?; - - // Extract just the [auth_verifier] section and parse it. - let parsed: toml::Value = toml::from_str(&toml_content)?; + let toml_content = r#" +[auth_verifier] +auth_verifier_url = "https://localhost:8443" +auth_verifier_accept_invalid_certs = true +auth_verifier_realm = "_" +"#; + let parsed: toml::Value = toml::from_str(toml_content)?; let auth_section = parsed .get("auth_verifier") .ok_or("missing [auth_verifier] section")?; @@ -179,7 +284,7 @@ mod tests { cfg.auth_verifier_url.as_deref(), Some("https://localhost:8443") ); - assert_eq!(cfg.auth_verifier_realm.as_deref(), Some("_")); + assert_eq!(cfg.primary_realm(), Some("_")); assert!(cfg.auth_verifier_accept_invalid_certs); assert_eq!( cfg.jwks_uri().as_deref(), diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 44a76482cf..da7c893860 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use super::{ AuthVerifierConfig, CrlConfig, GoogleCseConfig, HsmConfig, HttpConfig, IdpAuthConfig, - JwksEndpointConfig, KmipPolicyConfig, MainDBConfig, OcspConfig, RolesConfig, WorkspaceConfig, - logging::LoggingConfig, secret_backends::SecretBackendConfig, ui_config::UiConfig, - vault_config::VaultConfig, + JwksEndpointConfig, KmipPolicyConfig, MainDBConfig, OcspConfig, OpaConfig, RolesConfig, + WorkspaceConfig, logging::LoggingConfig, secret_backends::SecretBackendConfig, + ui_config::UiConfig, vault_config::VaultConfig, }; use crate::{ config::{AzureEkmConfig, ProxyConfig, SocketServerConfig, TlsConfig}, @@ -72,6 +72,7 @@ impl Default for ClapConfig { roles: RolesConfig::default(), privileged_users: None, aws_xks_config: AwsXksConfig::default(), + opa: OpaConfig::default(), kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), auto_rotation_check_interval_secs: 0, @@ -234,6 +235,9 @@ pub struct ClapConfig { #[clap(flatten)] pub aws_xks_config: AwsXksConfig, + #[clap(flatten)] + pub opa: OpaConfig, + /// KMIP algorithm policy. /// /// This policy is configured via parameter-specific allowlists under `[kmip.allowlists]`. @@ -767,6 +771,7 @@ impl fmt::Debug for ClapConfig { &self.auto_rotation_check_interval_secs, ); let x = x.field("keyset_warn_depth", &self.keyset_warn_depth); + let x = x.field("opa", &self.opa); x.finish() } diff --git a/crate/server/src/config/command_line/mod.rs b/crate/server/src/config/command_line/mod.rs index 74721af4bc..b21046d994 100644 --- a/crate/server/src/config/command_line/mod.rs +++ b/crate/server/src/config/command_line/mod.rs @@ -11,6 +11,7 @@ mod jwks_endpoint_config; mod kmip_policy_config; mod logging; mod ocsp_config; +mod opa_config; mod proxy_config; mod roles_config; pub mod secret_backends; @@ -37,6 +38,7 @@ pub use kmip_policy_config::{ }; pub use logging::{LoggingConfig, get_default_rolling_log_dir}; pub use ocsp_config::{NoncePolicyConfig, OcspConfig}; +pub use opa_config::OpaConfig; pub use proxy_config::ProxyConfig; pub use roles_config::RolesConfig; pub use secret_backends::{ diff --git a/crate/server/src/config/command_line/opa_config.rs b/crate/server/src/config/command_line/opa_config.rs new file mode 100644 index 0000000000..53f500d3b3 --- /dev/null +++ b/crate/server/src/config/command_line/opa_config.rs @@ -0,0 +1,68 @@ +//! OPA (Open Policy Agent) CLI configuration. + +use clap::Parser; +use serde::{Deserialize, Serialize}; + +fn default_opa_mode() -> String { + "disabled".to_owned() +} + +/// OPA sidecar integration configuration. +#[derive(Parser, Serialize, Deserialize, Clone, Debug)] +pub struct OpaConfig { + /// OPA sidecar base URL. Setting this enables OPA authorization. + /// Example: `http://localhost:8181` + #[clap(long, env = "KMS_OPA_URL")] + pub opa_url: Option, + + /// OPA evaluation mode: `"exclusive"` (OPA only) or `"enforcing"` (OPA gates access; + /// for operations on existing objects, a legacy DB grant is also required). + /// For object-creation operations (`Create`, `CreateKeyPair`, `Import`, `Register`) in + /// `"enforcing"` mode, OPA's allow decision is sufficient — no DB grant exists yet. + /// Ignored when `--opa-url` is not set. + #[clap(long, env = "KMS_OPA_MODE", default_value = "disabled")] + #[serde(default = "default_opa_mode")] + pub opa_mode: String, +} + +impl Default for OpaConfig { + fn default() -> Self { + Self { + opa_url: None, + opa_mode: "disabled".to_owned(), + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::core::opa::OpaMode; + + // ── OpaConfig::default ────────────────────────────────────────────────── + + /// Default config must have no OPA URL (OPA disabled) and mode string + /// `"disabled"` — guarantees backward compatibility for operators who + /// do not configure OPA. + #[test] + fn test_opa_config_default_has_no_url_and_disabled_mode() { + let cfg = OpaConfig::default(); + assert!( + cfg.opa_url.is_none(), + "default OPA URL must be None (OPA disabled)" + ); + assert_eq!( + cfg.opa_mode, "disabled", + "default OPA mode must be 'disabled'" + ); + } + + /// The default mode string must parse as `OpaMode::Disabled`. + #[test] + fn test_opa_config_default_mode_string_parses_as_disabled() { + let cfg = OpaConfig::default(); + let mode: OpaMode = cfg.opa_mode.parse().expect("default mode must be valid"); + assert_eq!(mode, OpaMode::Disabled); + } +} diff --git a/crate/server/src/config/command_line/roles_config.rs b/crate/server/src/config/command_line/roles_config.rs index 714392b037..a2ca00e159 100644 --- a/crate/server/src/config/command_line/roles_config.rs +++ b/crate/server/src/config/command_line/roles_config.rs @@ -18,10 +18,10 @@ use serde::{Deserialize, Serialize}; #[derive(Args, Clone, Deserialize, Serialize, Default)] #[serde(default)] pub struct RolesConfig { - /// Users with the Crypto Officer role. + /// Users with the Crypto Officer role (ISO/IEC 19790 "Crypto Officer" / PKCS#11 `CKU_SO`). /// /// May manage key lifecycle (create, import, certify, rekey, activate, revoke, destroy) - /// and access raw key material. + /// and access raw key material (get, export — "key output" per ISO/IEC 19790 §7.4.3). /// When active, gains ownership bypass on all Managed Objects. /// When set, only listed users (plus those explicitly granted the `Create` right) can /// create and import objects. @@ -32,7 +32,8 @@ pub struct RolesConfig { /// /// When `true`, users listed in `crypto_officer_users` are candidates only — /// the role is inactive until a KMIP `JoinSplitKey` with all shares tagged - /// `x-cosmian-crypto-officer-ceremony` completes. + /// `x-cosmian-crypto-officer-ceremony` completes + /// (NIST SP 800-57 Part 2 Rev 1 §4.6 split knowledge, XOR n-of-n). #[clap(long, verbatim_doc_comment, default_value = "false")] pub crypto_officer_require_ceremony: bool, @@ -47,15 +48,13 @@ pub struct RolesConfig { #[clap(long, env = "KMS_CEREMONY_SECRET", verbatim_doc_comment)] pub ceremony_secret: Option, - /// UID of a KMS symmetric key to use as the ceremony record sealing key. + /// UID of a KMS symmetric key to use as the ceremony record sealing key (ADP-26). /// - /// When set, key material is fetched from the KMS object store after database - /// initialization and used in place of `ceremony_secret`. This enables: + /// When set, key material is fetched from the KMS object store via a direct DB read + /// (bypassing KMIP auth) and used in place of `ceremony_secret`. This enables: /// - Key rotation via standard KMIP `ReKey` / `Rotate` operations. /// - HSM-backed sealing when the referenced key is HSM-resident. - /// - Audit trail: each retrieval of the ceremony key is logged. - /// - /// If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. + /// - Audit trail: each `Get` of the ceremony key is logged. /// /// **Bootstrap constraint**: the ceremony sealing key must be created before /// enabling `crypto_officer_require_ceremony = true`. Create it while the server @@ -68,6 +67,11 @@ pub struct RolesConfig { /// # 3. Set ceremony_key_id = "ceremony-seal-2026" in kms.toml /// # 4. Enable require_ceremony = true and restart /// ``` + /// + /// If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. + /// + /// **Status**: ADP-26 (planned). This field is accepted by the config parser but is not yet + /// functional. Set `ceremony_secret` in the meantime. #[clap(long, env = "KMS_CEREMONY_KEY_ID", verbatim_doc_comment)] pub ceremony_key_id: Option, diff --git a/crate/server/src/config/command_line/ui_config.rs b/crate/server/src/config/command_line/ui_config.rs index 4e3500aa41..62de982c59 100644 --- a/crate/server/src/config/command_line/ui_config.rs +++ b/crate/server/src/config/command_line/ui_config.rs @@ -147,7 +147,7 @@ pub struct OidcRuntimeConfig { /// /// `jwks_manager` is the *same* manager built for the bearer-token `AuthVerifier` /// middleware (see `prepare_kms_server`) — no second JWKS fetch is performed. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct AuthVerifierRuntimeConfig { /// The static Auth Verifier configuration (server URL, realm, TLS options). pub config: crate::config::AuthVerifierConfig, diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index 7773e7f1fb..88b6566235 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -149,21 +149,23 @@ pub struct ServerParams { /// The non-revocable key ID used for demo purposes pub non_revocable_key_id: Option>, + /// OPA (Open Policy Agent) RBAC evaluation parameters. + /// When set, the KMS calls OPA before (or instead of) its internal permission check. + pub(crate) opa_params: Option, + /// Crypto Officer role configuration (role-based access control). pub crypto_officer: CryptoOfficerConfig, /// Ceremony record encryption keys. /// - /// Derived from `ceremony_secret` at startup, or resolved from the object - /// store when `ceremony_key_id` is set. `None` when no role requires a ceremony. + /// Derived from `ceremony_secret` at startup. `None` when no role requires a ceremony. /// When `Some`, all ceremony activation records are AES-256-GCM sealed before storage /// and verified on read — preventing forgery and protecting participant identities. pub ceremony_keys: Option>, - - /// UID of the KMS symmetric key used as the ceremony record sealing key. + /// UID of a KMS symmetric key used to derive ceremony sealing keys at startup. /// - /// When set, `ceremony_key_id` takes precedence over `ceremony_secret`. - /// The key is fetched from the object store after database initialization. + /// When set, this takes precedence over `ceremony_secret`; raw key bytes are fetched + /// from the object store and converted into [`CeremonyKeys`]. pub ceremony_key_id: Option, /// AWS XKS parameters, if any @@ -458,6 +460,25 @@ impl ServerParams { None }, non_revocable_key_id: conf.non_revocable_key_id, + opa_params: { + let mode = conf + .opa + .opa_mode + .parse::() + .map_err(|_e| KmsError::InvalidRequest( + "invalid `opa_mode` value; expected one of: disabled, exclusive, enforcing".to_owned() + ))?; + match (conf.opa.opa_url, mode) { + (None, crate::core::opa::OpaMode::Disabled) => None, + (None, active_mode) => { + return Err(KmsError::InvalidRequest(format!( + "`--opa-mode {active_mode}` requires `--opa-url` to be set; \ + OPA cannot be active without a server URL" + ))); + } + (Some(url), mode) => Some(crate::core::opa::OpaParams { url, mode }), + } + }, crypto_officer: { // Backward compat: if the deprecated `privileged_users` field is set and // `[roles] crypto_officer_users` is not configured, promote those users to @@ -496,16 +517,8 @@ impl ServerParams { }, ceremony_keys: { let any_ceremony_required = conf.roles.crypto_officer_require_ceremony; - match ( - &conf.roles.ceremony_key_id, - &conf.roles.ceremony_secret, - any_ceremony_required, - ) { - // ceremony_key_id takes precedence — keys resolved after DB init; - // or neither provided and ceremony is not required. - (Some(_), _, _) | (None, None, false) => None, - // Only ceremony_secret provided — derive keys now - (None, Some(hex_secret), _) => { + match (&conf.roles.ceremony_secret, any_ceremony_required) { + (Some(hex_secret), _) => { let bytes = hex::decode(hex_secret).map_err(|e| { KmsError::ServerError(format!( "ceremony_secret: invalid hex encoding: {e}" @@ -533,18 +546,17 @@ impl ServerParams { ); Some(Arc::new(keys)) } - // Neither provided but ceremony required - (None, None, true) => { + (None, true) => { return Err(KmsError::ServerError( - "ceremony_secret or ceremony_key_id is required when any role has \ - require_ceremony = true. Set ceremony_key_id to an existing AES-256 \ - symmetric key UID, or generate a secret with: openssl rand -hex 32" + "ceremony_secret is required when any role has require_ceremony = true. \ + Generate one with: openssl rand -hex 32" .to_owned(), )); } + (None, false) => None, } }, - ceremony_key_id: conf.roles.ceremony_key_id.clone(), + ceremony_key_id: conf.roles.ceremony_key_id, ui_session_salt: conf.ui_config.ui_session_salt, proxy_params: ProxyParams::try_from(&conf.proxy) .context("failed to create ProxyParams")?, @@ -1051,6 +1063,14 @@ impl fmt::Debug for ServerParams { } } + if let Some(ref opa) = self.opa_params { + debug_struct + .field("opa_url", &opa.url) + .field("opa_mode", &opa.mode); + } else { + debug_struct.field("opa_mode", &"disabled"); + } + debug_struct.field( "ceremony_keys", &self.ceremony_keys.as_ref().map(|_| ""), diff --git a/crate/server/src/config/wizard/auth_wizard.rs b/crate/server/src/config/wizard/auth_wizard.rs index 33c8f8099c..4c5d70419e 100644 --- a/crate/server/src/config/wizard/auth_wizard.rs +++ b/crate/server/src/config/wizard/auth_wizard.rs @@ -148,12 +148,24 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult = if enable_ui_login { + let realm: Option> = if enable_ui_login { let realm: String = Input::with_theme(&theme) - .with_prompt("Realm to authenticate the Web UI against") + .with_prompt( + "Realm(s) to authenticate the Web UI against (comma-separated for multiple)", + ) .interact_text() .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; - Some(realm) + let realms: Vec = realm + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if realms.is_empty() { + None + } else { + Some(realms) + } } else { None }; diff --git a/crate/server/src/core/kms/mod.rs b/crate/server/src/core/kms/mod.rs index 62d9460838..1c95b9972b 100644 --- a/crate/server/src/core/kms/mod.rs +++ b/crate/server/src/core/kms/mod.rs @@ -109,6 +109,9 @@ pub struct KMS { /// across server restarts. The `fetch_add` ensures uniqueness even when /// two CRLs are generated within the same second. pub(crate) crl_counter: Arc, + + /// Optional OPA client for RBAC evaluation (Phases 7-8). + pub(crate) opa_client: Option>, } impl KMS { @@ -285,6 +288,14 @@ impl KMS { Arc::new(AtomicU64::new(ts_seed.max(db_max + 1))) }; + // Instantiate OPA client if configured + let opa_client = server_params + .opa_params + .as_ref() + .map(|opa| super::opa::OpaClient::new(&opa.url)) + .transpose()? + .map(Arc::new); + Ok(Self { params: server_params.clone(), database, @@ -293,6 +304,7 @@ impl KMS { hsm: hsm_instances.into_iter().next(), metrics, crl_counter, + opa_client, }) } diff --git a/crate/server/src/core/kms/permissions.rs b/crate/server/src/core/kms/permissions.rs index 1d4a707ba8..f4d905d97a 100644 --- a/crate/server/src/core/kms/permissions.rs +++ b/crate/server/src/core/kms/permissions.rs @@ -12,6 +12,7 @@ use cosmian_kms_server_database::reexport::{ use crate::{ core::{ KMS, + opa::{OpaMode, OpaUserContext}, retrieve_object_utils::user_has_permission, uid_utils::{ObjectHandle, from_request}, }, @@ -272,9 +273,35 @@ impl KMS { /// intentional: Rekey has creation semantics that warrant the same lifecycle gate as `Create`. pub(crate) async fn enforce_create_permission(&self, user: &UserId) -> KResult<()> { let co_users = &self.params.crypto_officer.users; + + // The `default_username` (unauthenticated / local access) and users + // explicitly listed as KMS-native CryptoOfficers always retain the + // `Create` right. CryptoOfficers are a server-level role (e.g. split-key + // ceremony participants) that must be able to create keys regardless of + // the OPA RBAC roles carried in the request's JWT. + let is_privileged = + *user == self.params.default_username || co_users.iter().any(|u| u == user.as_str()); + + // When OPA is active (enforcing or exclusive mode), OPA is the + // authoritative gatekeeper for Create for every non-privileged user. + // This ensures that roles like Auditor, User, or no-role are denied by + // the OPA policy even when no explicit crypto-officer list is set. + let opa_mode = self + .params + .opa_params + .as_ref() + .map_or(OpaMode::Disabled, |p| p.mode); + if self.opa_client.is_some() && opa_mode != OpaMode::Disabled && !is_privileged { + if user_has_permission(user, None, &KmipOperation::Create, self).await? { + return Ok(()); + } + kms_bail!(KmsError::Unauthorized( + "User does not have create access-right.".to_owned() + )) + } + if !co_users.is_empty() { - if *user == self.params.default_username - || co_users.iter().any(|u| u == user.as_str()) + if is_privileged || user_has_permission(user, None, &KmipOperation::Create, self).await? { return Ok(()); @@ -485,4 +512,43 @@ impl KMS { Ok(()) } + + /// Extract the per-request OPA user context (roles + domain) from the request. + /// Call this in route handlers that perform OPA-guarded operations and wrap the + /// async work in `OPA_USER_CONTEXT.scope(ctx, fut).await`. + pub(crate) fn extract_opa_context(&self, req_http: &HttpRequest) -> OpaUserContext { + if self.params.force_default_username { + return OpaUserContext::default(); + } + req_http + .extensions() + .get::() + .map_or_else(OpaUserContext::default, |au| OpaUserContext { + roles: au.roles.clone(), + domain: au.domain.clone(), + }) + } + + /// Return the `UserId` of the first active Crypto Officer, or `None`. + /// + /// Iterates `crypto_officer.users` in declaration order and returns the first + /// candidate for which [`KMS::is_crypto_officer`] returns `true`. + /// + /// Falls back to `None` when: + /// - `crypto_officer.users` is empty (no CO is configured), **or** + /// - CO users are configured but none has completed the required ceremony. + /// + /// Callers that need to operate on behalf of a CO (e.g. fire-and-forget CRL + /// regeneration after a `Revoke`) should use this helper to obtain an identity + /// that is guaranteed to pass the `is_crypto_officer` check inside + /// `generate_crl`. + pub(crate) async fn find_active_co(&self) -> KResult> { + for candidate in &self.params.crypto_officer.users { + let uid = UserId::from(candidate.as_str()); + if self.is_crypto_officer(&uid).await? { + return Ok(Some(uid)); + } + } + Ok(None) + } } diff --git a/crate/server/src/core/mod.rs b/crate/server/src/core/mod.rs index e76183062d..9e3438182c 100644 --- a/crate/server/src/core/mod.rs +++ b/crate/server/src/core/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod certificate; #[cfg(feature = "non-fips")] pub(crate) mod cover_crypt; pub(crate) mod kms; +pub(crate) mod opa; pub(crate) mod operations; pub(crate) mod otel_metrics; pub(crate) mod retrieve_object_utils; diff --git a/crate/server/src/core/opa/client.rs b/crate/server/src/core/opa/client.rs new file mode 100644 index 0000000000..e5b00a4209 --- /dev/null +++ b/crate/server/src/core/opa/client.rs @@ -0,0 +1,158 @@ +//! OPA HTTP client — fail-closed design. + +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; + +use super::OpaInput; +use crate::{error::KmsError, result::KResult}; + +/// Wrapper around the OPA REST API. +/// +/// Evaluates the KMS RBAC policy at `POST /v1/data/kms/allow`. +/// Fail-closed: any transport or parsing error results in denial. +pub(crate) struct OpaClient { + client: Client, + /// Full URL to the OPA decision endpoint (e.g. `http://localhost:8181/v1/data/kms/allow`). + decision_url: String, +} + +/// OPA response shape for a simple boolean policy. +#[derive(Deserialize)] +struct OpaResponse { + result: Option, +} + +/// Wrapper for the `input` field required by the OPA Data API. +#[derive(serde::Serialize)] +struct OpaRequest<'a> { + input: &'a OpaInput, +} + +impl OpaClient { + /// Create a new OPA client targeting the given base URL. + /// + /// The base URL should be the OPA server root (e.g. `http://localhost:8181`). + /// The decision path `/v1/data/kms/allow` is appended automatically. + pub(crate) fn new(base_url: &str) -> KResult { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| KmsError::ServerError(format!("OPA client init failed: {e}")))?; + let decision_url = format!("{}/v1/data/kms/allow", base_url.trim_end_matches('/')); + Ok(Self { + client, + decision_url, + }) + } + + /// Query OPA for a decision. Returns `true` if allowed, `false` if denied. + /// + /// Any error (network, timeout, parse failure) is treated as denial (fail-closed). + pub(crate) async fn query(&self, input: &OpaInput) -> KResult { + let body = OpaRequest { input }; + let resp = self + .client + .post(&self.decision_url) + .json(&body) + .send() + .await + .map_err(|e| { + warn!("OPA request failed (fail-closed deny): {e}"); + KmsError::ServerError(format!("OPA unreachable: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + warn!("OPA returned non-2xx (fail-closed deny): {status} — {body_text}"); + return Ok(false); + } + + let opa_resp: OpaResponse = resp.json().await.map_err(|e| { + warn!("OPA response parse failed (fail-closed deny): {e}"); + KmsError::ServerError(format!("OPA response parse error: {e}")) + })?; + + Ok(opa_resp.result.unwrap_or(false)) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + // ── OpaClient::new — URL construction ──────────────────────────────────── + // + // These tests exercise `OpaClient::new` without making any network calls. + // They verify that the decision URL is assembled correctly so that + // `OpaClient::query` will hit the right OPA REST endpoint. + + /// The decision URL is formed as `{base_url}/v1/data/kms/allow`. + #[test] + fn test_opa_client_new_constructs_correct_decision_url() { + let client = OpaClient::new("http://localhost:8181") + .expect("OpaClient::new must succeed with a valid URL"); + assert_eq!( + client.decision_url, + "http://localhost:8181/v1/data/kms/allow" + ); + } + + /// A trailing `/` on the base URL is stripped before appending the path, + /// so `http://opa:8181/` and `http://opa:8181` produce identical URLs. + #[test] + fn test_opa_client_new_trims_trailing_slash() { + let client = OpaClient::new("http://opa:8181/").expect("OpaClient::new must succeed"); + assert_eq!( + client.decision_url, "http://opa:8181/v1/data/kms/allow", + "trailing slash must be removed before appending path" + ); + } + + /// Base URL with a path prefix is handled correctly (custom OPA mount point). + #[test] + fn test_opa_client_new_preserves_path_prefix() { + let client = + OpaClient::new("http://opa:8181/kms-opa").expect("OpaClient::new must succeed"); + assert_eq!( + client.decision_url, + "http://opa:8181/kms-opa/v1/data/kms/allow" + ); + } + + // ── OpaResponse deserialization ────────────────────────────────────────── + + /// `{"result": true}` deserializes as `Some(true)`. + #[test] + fn test_opa_response_result_true() { + let r: OpaResponse = serde_json::from_str(r#"{"result":true}"#).unwrap(); + assert_eq!(r.result, Some(true)); + } + + /// `{"result": false}` deserializes as `Some(false)`. + #[test] + fn test_opa_response_result_false() { + let r: OpaResponse = serde_json::from_str(r#"{"result":false}"#).unwrap(); + assert_eq!(r.result, Some(false)); + } + + /// `{}` (missing `result` key — undefined Rego rule) deserializes as `None`, + /// which `query()` maps to `false` (fail-closed). + #[test] + fn test_opa_response_missing_result_is_none() { + let r: OpaResponse = serde_json::from_str("{}").unwrap(); + assert!( + r.result.is_none(), + "missing result must deserialize as None" + ); + } + + /// `{"result": null}` (undefined OPA policy) deserializes as `None`. + #[test] + fn test_opa_response_null_result_is_none() { + let r: OpaResponse = serde_json::from_str(r#"{"result":null}"#).unwrap(); + assert!(r.result.is_none()); + } +} diff --git a/crate/server/src/core/opa/config.rs b/crate/server/src/core/opa/config.rs new file mode 100644 index 0000000000..7a66ff42ad --- /dev/null +++ b/crate/server/src/core/opa/config.rs @@ -0,0 +1,119 @@ +//! OPA client configuration types. + +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +/// The OPA evaluation mode for the KMS permission layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub(crate) enum OpaMode { + /// OPA is not consulted; existing KMS permission logic runs unchanged. + #[default] + Disabled, + /// OPA is the sole decision maker; KMS permission logic is skipped entirely. + Exclusive, + /// OPA runs first; if it denies, the request is denied immediately. + /// If it allows, the KMS permission logic also runs (both must allow). + Enforcing, +} + +impl fmt::Display for OpaMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => write!(f, "disabled"), + Self::Exclusive => write!(f, "exclusive"), + Self::Enforcing => write!(f, "enforcing"), + } + } +} + +impl FromStr for OpaMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "disabled" => Ok(Self::Disabled), + "exclusive" => Ok(Self::Exclusive), + "enforcing" => Ok(Self::Enforcing), + other => Err(format!( + "invalid OPA mode '{other}': expected 'disabled', 'exclusive', or 'enforcing'" + )), + } + } +} + +/// Configuration for the OPA sidecar integration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct OpaParams { + /// Base URL of the OPA server (e.g. `http://localhost:8181`). + pub url: String, + /// Evaluation mode. + pub mode: OpaMode, +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + // ── OpaMode::from_str ──────────────────────────────────────────────────── + + /// All three valid mode strings parse to the correct variant. + #[test] + fn test_opa_mode_from_str_all_valid_values() { + assert_eq!("disabled".parse::().unwrap(), OpaMode::Disabled); + assert_eq!("exclusive".parse::().unwrap(), OpaMode::Exclusive); + assert_eq!("enforcing".parse::().unwrap(), OpaMode::Enforcing); + } + + /// Parsing is case-insensitive (`"EXCLUSIVE"`, `"Enforcing"` etc. are accepted). + #[test] + fn test_opa_mode_from_str_case_insensitive() { + assert_eq!("DISABLED".parse::().unwrap(), OpaMode::Disabled); + assert_eq!("EXCLUSIVE".parse::().unwrap(), OpaMode::Exclusive); + assert_eq!("Enforcing".parse::().unwrap(), OpaMode::Enforcing); + } + + /// An unrecognised string returns an `Err` with a helpful message. + #[test] + fn test_opa_mode_from_str_invalid_returns_error() { + let err = "permissive".parse::().unwrap_err(); + assert!( + err.contains("invalid OPA mode"), + "error message should mention 'invalid OPA mode', got: {err}" + ); + assert!(err.contains("permissive")); + } + + // ── OpaMode::Display ───────────────────────────────────────────────────── + + /// Each variant formats to the expected lowercase string used in config and logs. + #[test] + fn test_opa_mode_display() { + assert_eq!(OpaMode::Disabled.to_string(), "disabled"); + assert_eq!(OpaMode::Exclusive.to_string(), "exclusive"); + assert_eq!(OpaMode::Enforcing.to_string(), "enforcing"); + } + + // ── OpaMode::Default ───────────────────────────────────────────────────── + + /// `OpaMode::default()` must be `Disabled` so that servers without OPA config + /// behave identically to pre-OPA deployments (backward-compatibility invariant). + #[test] + fn test_opa_mode_default_is_disabled() { + assert_eq!(OpaMode::default(), OpaMode::Disabled); + } + + // ── round-trip ─────────────────────────────────────────────────────────── + + /// `Display` → `from_str` round-trip is identity for every variant. + #[test] + fn test_opa_mode_display_from_str_round_trip() { + for mode in [OpaMode::Disabled, OpaMode::Exclusive, OpaMode::Enforcing] { + let s = mode.to_string(); + let parsed: OpaMode = s.parse().expect("round-trip parse must succeed"); + assert_eq!(parsed, mode, "round-trip failed for {mode}"); + } + } +} diff --git a/crate/server/src/core/opa/context.rs b/crate/server/src/core/opa/context.rs new file mode 100644 index 0000000000..79f2088f17 --- /dev/null +++ b/crate/server/src/core/opa/context.rs @@ -0,0 +1,129 @@ +//! Per-request OPA user context stored in a task-local variable. +//! +//! Using `tokio::task_local!` (rather than `thread_local!`) ensures the context +//! is bound to the logical async *task* (i.e. a single HTTP request) and not to +//! the underlying OS thread. With a multi-threaded Tokio runtime, tasks can +//! migrate between OS threads on every `.await` point; a `thread_local!` value +//! set before an `.await` may therefore be invisible — or, worse, belong to a +//! *different* request — when the task resumes on another thread. +//! +//! Route handlers that perform OPA-guarded operations must wrap their async work +//! in `OPA_USER_CONTEXT.scope(ctx, fut).await` to set the context for the +//! duration of that future. + +use tokio::task_local; + +/// Per-request context for OPA policy evaluation. +#[derive(Debug, Clone, Default)] +pub(crate) struct OpaUserContext { + /// RBAC roles from the JWT (empty if not present or not authenticated via JWT). + pub roles: Vec, + /// Domain from the JWT `as_domain` private claim. + pub domain: Option, +} + +task_local! { + /// Task-local OPA user context. Valid only within a `scope()` block set by + /// the route handler. Defaults to an empty context if accessed outside a scope + /// (e.g. in tests that do not configure OPA), which results in fail-closed OPA + /// behavior. + pub(crate) static OPA_USER_CONTEXT: OpaUserContext; +} + +/// Read the OPA user context for the current task. +/// Returns an empty (zero-privilege) context when called outside a scope. +pub(crate) fn get_opa_user_context() -> OpaUserContext { + OPA_USER_CONTEXT.try_with(Clone::clone).unwrap_or_default() +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + // ── OpaUserContext::default ────────────────────────────────────────────── + + /// Default context must be zero-privilege: empty roles and no domain. + /// This is the fail-closed starting state for any request that did not + /// set an explicit context. + #[test] + fn test_opa_user_context_default_zero_privilege() { + let ctx = OpaUserContext::default(); + assert!(ctx.roles.is_empty(), "default roles must be empty"); + assert!(ctx.domain.is_none(), "default domain must be None"); + } + + // ── get_opa_user_context outside scope ─────────────────────────────────── + + /// Calling `get_opa_user_context()` outside of an `OPA_USER_CONTEXT.scope()` + /// must return the default zero-privilege context instead of panicking. + /// This is critical for correctness: operations that don't set a context + /// must fail-closed (no roles → OPA denies). + #[tokio::test] + async fn test_get_opa_user_context_outside_scope_returns_default() { + let ctx = get_opa_user_context(); + assert!(ctx.roles.is_empty()); + assert!(ctx.domain.is_none()); + } + + // ── get_opa_user_context within scope ──────────────────────────────────── + + /// Inside `OPA_USER_CONTEXT.scope(ctx, fut)`, `get_opa_user_context()` + /// must return exactly the value that was placed in scope. + #[tokio::test] + async fn test_get_opa_user_context_within_scope_returns_value() { + let expected = OpaUserContext { + roles: vec!["CryptoOfficer".to_owned()], + domain: Some("acme.com".to_owned()), + }; + let result = OPA_USER_CONTEXT + .scope(expected.clone(), async { get_opa_user_context() }) + .await; + assert_eq!(result.roles, expected.roles); + assert_eq!(result.domain, expected.domain); + } + + /// After the scope future completes, `get_opa_user_context()` reverts to + /// the default — the task-local is not leaked across scope boundaries. + #[tokio::test] + async fn test_get_opa_user_context_scope_does_not_leak() { + let ctx = OpaUserContext { + roles: vec!["SuperAdmin".to_owned()], + domain: Some("leak-test".to_owned()), + }; + OPA_USER_CONTEXT.scope(ctx, async { /* nothing */ }).await; + // After the scope, the task-local is no longer set. + let after = get_opa_user_context(); + assert!( + after.roles.is_empty(), + "roles must be empty after scope exits, got {:?}", + after.roles + ); + assert!(after.domain.is_none()); + } + + /// Scopes with different contexts can be nested: the inner scope's value + /// is visible inside, and the outer scope's value is visible outside. + #[tokio::test] + async fn test_get_opa_user_context_nested_scopes() { + let outer = OpaUserContext { + roles: vec!["DomainAdmin".to_owned()], + domain: Some("outer.com".to_owned()), + }; + let inner = OpaUserContext { + roles: vec!["Auditor".to_owned()], + domain: Some("inner.com".to_owned()), + }; + let (outer_seen, inner_seen) = OPA_USER_CONTEXT + .scope(outer.clone(), async { + let outer_ctx = get_opa_user_context(); + let inner_ctx = OPA_USER_CONTEXT + .scope(inner.clone(), async { get_opa_user_context() }) + .await; + (outer_ctx, inner_ctx) + }) + .await; + assert_eq!(outer_seen.roles, outer.roles); + assert_eq!(inner_seen.roles, inner.roles); + } +} diff --git a/crate/server/src/core/opa/input.rs b/crate/server/src/core/opa/input.rs new file mode 100644 index 0000000000..df7cecca26 --- /dev/null +++ b/crate/server/src/core/opa/input.rs @@ -0,0 +1,126 @@ +//! OPA input document for the KMS RBAC policy. + +use serde::Serialize; + +/// The input document sent to OPA for evaluation. +/// +/// Evaluated at: `POST {opa_url}/v1/data/kms/allow` +/// +/// See `test_data/opa/kms.rego` for the Rego policy that consumes this input. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct OpaInput { + /// Authenticated identity (JWT `sub`, TLS CN, or API-token id). + pub user: String, + /// Domain from the `as_domain` JWT private claim; `""` for non-JWT auth. + pub user_domain: String, + /// Roles from the JWT `roles` claim (RFC 9068); `[]` for non-JWT auth (fail-closed). + pub roles: Vec, + /// KMIP operation name as returned by `KmipOperation::to_string()` (lowercase `snake_case`, + /// e.g. `"create"`, `"decrypt"`, `"get_attributes"`). + pub operation: String, + /// UID of the target KMIP object; `"*"` for object-less operations. + pub object_uid: String, + /// Domain the target object belongs to. + /// For object-less operations (e.g. `Create`, `Locate`) this is set to `user_domain` so + /// that the `same_domain` Rego rule passes for the caller's own domain. + /// For operations on existing objects this is the `domain` column value stored with the object. + pub object_domain: String, + /// Whether the caller is the owner of the target object. + pub is_owner: bool, +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + fn sample_input(is_owner: bool) -> OpaInput { + OpaInput { + user: "alice@acme.com".to_owned(), + user_domain: "acme".to_owned(), + roles: vec!["CryptoOfficer".to_owned()], + operation: "get".to_owned(), + object_uid: "uid-001".to_owned(), + object_domain: "acme".to_owned(), + is_owner, + } + } + + /// All seven OPA input fields serialize with the exact `snake_case` names the + /// Rego policy expects. A mismatch silently breaks policy evaluation. + #[test] + fn test_opa_input_serializes_all_expected_field_names() { + let input = sample_input(true); + let json = serde_json::to_string(&input).expect("OpaInput must serialize"); + for field in &[ + "user", + "user_domain", + "roles", + "operation", + "object_uid", + "object_domain", + "is_owner", + ] { + assert!( + json.contains(&format!("\"{field}\"")), + "serialized JSON must contain field '{field}', got: {json}" + ); + } + } + + /// `is_owner: true` serializes as JSON `true` (not `"true"` or `1`). + #[test] + fn test_opa_input_is_owner_true_serializes_as_json_boolean() { + let input = sample_input(true); + let json = serde_json::to_string(&input).expect("serialize"); + assert!( + json.contains("\"is_owner\":true"), + "is_owner must be JSON true, got: {json}" + ); + } + + /// `is_owner: false` serializes as JSON `false`. + #[test] + fn test_opa_input_is_owner_false_serializes_as_json_boolean() { + let input = sample_input(false); + let json = serde_json::to_string(&input).expect("serialize"); + assert!( + json.contains("\"is_owner\":false"), + "is_owner must be JSON false, got: {json}" + ); + } + + /// `roles` serializes as a JSON array (not a comma-separated string). + #[test] + fn test_opa_input_roles_serializes_as_json_array() { + let mut input = sample_input(false); + input.roles = vec!["CryptoOfficer".to_owned(), "Auditor".to_owned()]; + let json = serde_json::to_string(&input).expect("serialize"); + assert!( + json.contains("\"roles\":["), + "roles must be a JSON array, got: {json}" + ); + assert!(json.contains("\"CryptoOfficer\"")); + assert!(json.contains("\"Auditor\"")); + } + + /// An object-less input has `object_uid = "*"` and `is_owner = false`. + #[test] + fn test_opa_input_objectless_wildcard_uid() { + let input = OpaInput { + user: "alice@acme.com".to_owned(), + user_domain: "acme".to_owned(), + roles: vec![], + operation: "create".to_owned(), + object_uid: "*".to_owned(), + object_domain: "acme".to_owned(), + is_owner: false, + }; + let json = serde_json::to_string(&input).expect("serialize"); + assert!( + json.contains("\"object_uid\":\"*\""), + "object-less op must use '*', got: {json}" + ); + assert!(json.contains("\"is_owner\":false")); + } +} diff --git a/crate/server/src/core/opa/mod.rs b/crate/server/src/core/opa/mod.rs new file mode 100644 index 0000000000..f1d896094f --- /dev/null +++ b/crate/server/src/core/opa/mod.rs @@ -0,0 +1,14 @@ +//! OPA (Open Policy Agent) authorization integration. +//! +//! This module provides the OPA client and input document construction used +//! to evaluate RBAC decisions via a sidecar OPA server. + +mod client; +mod config; +mod context; +mod input; + +pub(crate) use client::OpaClient; +pub(crate) use config::{OpaMode, OpaParams}; +pub(crate) use context::{OPA_USER_CONTEXT, OpaUserContext, get_opa_user_context}; +pub(crate) use input::OpaInput; diff --git a/crate/server/src/core/operations/auto_rotate.rs b/crate/server/src/core/operations/auto_rotate.rs index 53fd613b3a..124a749e2f 100644 --- a/crate/server/src/core/operations/auto_rotate.rs +++ b/crate/server/src/core/operations/auto_rotate.rs @@ -546,6 +546,7 @@ mod tests { &key_object, &key_attrs, &HashSet::new(), + "", ) .await .map_err(|e| crate::error::KmsError::ServerError(e.to_string()))?; @@ -615,6 +616,7 @@ mod tests { &key_object, &key_attrs, &HashSet::new(), + "", ) .await .is_ok() @@ -672,6 +674,7 @@ mod tests { &key_object, &key_attrs, &HashSet::new(), + "", ) .await .map_err(|e| crate::error::KmsError::ServerError(e.to_string()))?; @@ -1009,6 +1012,7 @@ mod tests { &key_object, &key_attrs, &HashSet::new(), + "", ) .await .map_err(|e| crate::error::KmsError::ServerError(e.to_string()))?; @@ -1092,6 +1096,7 @@ mod tests { &key_object, &key_attrs, &HashSet::new(), + "", ) .await .map_err(|e| crate::error::KmsError::ServerError(e.to_string()))?; diff --git a/crate/server/src/core/operations/create.rs b/crate/server/src/core/operations/create.rs index 76a13af37c..443ee72d7f 100644 --- a/crate/server/src/core/operations/create.rs +++ b/crate/server/src/core/operations/create.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use super::key_ops::ObjectLifecycleExt; use crate::{ - core::{KMS, uid_utils::ObjectHandle, wrapping::wrap_and_cache}, + core::{KMS, opa::get_opa_user_context, uid_utils::ObjectHandle, wrapping::wrap_and_cache}, error::KmsError, kms_bail, middlewares::UserId, @@ -99,6 +99,10 @@ pub(crate) async fn create(kms: &KMS, request: Create, owner: &UserId) -> KResul ); // Wrap the object if requested by the user or on the server params Box::pin(wrap_and_cache(kms, owner, &unique_identifier, &mut object)).await?; + + // Stamp the object with the creator's domain (from OPA user context) so that + // domain-scoped role checks (e.g. Auditor, DomainAdmin) can be evaluated later. + let creator_domain = get_opa_user_context().domain.unwrap_or_default(); // If the object was wrapped, record the WrappingKeyLink in the stored attributes // so KMIP GetAttributes returns it correctly (KMIP 2.1 §4.31 Link). object.copy_wrapping_key_link_to(&mut attributes); @@ -112,6 +116,7 @@ pub(crate) async fn create(kms: &KMS, request: Create, owner: &UserId) -> KResul &object, &attributes, &tags, + &creator_domain, ) .await?; info!( diff --git a/crate/server/src/core/operations/create_split_key.rs b/crate/server/src/core/operations/create_split_key.rs index c338f96b7d..a9279ad087 100644 --- a/crate/server/src/core/operations/create_split_key.rs +++ b/crate/server/src/core/operations/create_split_key.rs @@ -300,8 +300,6 @@ pub(crate) async fn create_split_key( // Build attributes for the share object — include crypto metadata so // GetAttributes and the WebUI Locate table can display algorithm / length / format. - // `sensitive = true` ensures Get/Export of a share require explicit key-wrapping - // (same protection as any other raw key material — CWE-312). let share_attrs = Attributes { state: Some(State::Active), object_type: Some(ObjectType::SplitKey), @@ -320,7 +318,6 @@ pub(crate) async fn create_split_key( .ok() .and_then(|kb| kb.cryptographic_length), key_format_type: Some(KeyFormatType::Opaque), - sensitive: Some(true), ..Attributes::default() }; let mut share_attrs = share_attrs; @@ -372,6 +369,7 @@ pub(crate) async fn create_split_key( &split_key_obj, &share_attrs, &tags, + "", ) .await { diff --git a/crate/server/src/core/operations/derive_key.rs b/crate/server/src/core/operations/derive_key.rs index 5fe801add7..cdf05d4b68 100644 --- a/crate/server/src/core/operations/derive_key.rs +++ b/crate/server/src/core/operations/derive_key.rs @@ -297,6 +297,7 @@ pub(crate) async fn derive_key( &derived_object, &attributes, &tags, + "", ) .await .map_err(|e| { diff --git a/crate/server/src/core/operations/generate_crl.rs b/crate/server/src/core/operations/generate_crl.rs index fc20299167..d302d28691 100644 --- a/crate/server/src/core/operations/generate_crl.rs +++ b/crate/server/src/core/operations/generate_crl.rs @@ -25,7 +25,7 @@ use cosmian_kms_server_database::reexport::{ kmip_private_key_to_openssl, }, }; -use cosmian_logger::{debug, trace, warn}; +use cosmian_logger::{debug, error, trace, warn}; use openssl::x509::{X509, X509Crl}; use time::OffsetDateTime; @@ -135,6 +135,25 @@ pub(crate) async fn generate_crl( issuer_certificate_id ); + // Guard: when CO users are configured, only an active CO may call this. + // CRL generation uses find_all (no user filter) — the CO role is the + // documented gating condition for that bypass (same as Locate with CO). + if !kms.params.crypto_officer.users.is_empty() && !kms.is_crypto_officer(user).await? { + return Err(KmsError::Unauthorized(format!( + "Generating a CRL requires the Crypto Officer role. \ + User '{user}' is not an active Crypto Officer." + ))); + } + if !kms.params.crypto_officer.users.is_empty() { + // Audit log — CO bypass is a high-value security event. + error!( + target: "audit", + user = %user, + issuer_id = issuer_certificate_id, + "CRYPTO_OFFICER_ACCESS: crypto officer generating CRL (find_all bypass)", + ); + } + // 1. Retrieve the issuer certificate let issuer_owm = retrieve_object_for_operation( ObjectHandle::Uid(issuer_certificate_id), diff --git a/crate/server/src/core/operations/join_split_key.rs b/crate/server/src/core/operations/join_split_key.rs index 044da92e6e..09a7ddffe2 100644 --- a/crate/server/src/core/operations/join_split_key.rs +++ b/crate/server/src/core/operations/join_split_key.rs @@ -385,6 +385,7 @@ pub(crate) async fn join_split_key( &reconstructed_object, &reconstructed_attrs, &tags, + "", ) .await?; @@ -500,13 +501,14 @@ pub(crate) async fn perform_crypto_officer_ceremony_activation( if !co_cfg.require_ceremony { kms_bail!(KmsError::InvalidRequest( - "This server uses config-only Crypto Officer mode: no ceremony is required.".to_owned() + "This server uses config-only Crypto Officer mode — no ceremony is required." + .to_owned() )); } if !co_cfg.users.iter().any(|u| u == user.as_str()) { kms_bail!(KmsError::Unauthorized( - "Ceremony activation rejected: the requesting user is not listed in \ + "Ceremony activation rejected — the requesting user is not listed in \ `crypto_officer_users`" .to_owned() )); @@ -516,7 +518,7 @@ pub(crate) async fn perform_crypto_officer_ceremony_activation( if !reconstructed.all_ceremony_tagged { kms_bail!(KmsError::Unauthorized( - "Ceremony activation rejected: not all shares are tagged with \ + "Ceremony activation rejected — not all shares are tagged with \ `x-cosmian-crypto-officer-ceremony`." .to_owned() )); @@ -527,14 +529,17 @@ pub(crate) async fn perform_crypto_officer_ceremony_activation( if unique_participants.len() != participants.len() { kms_bail!(KmsError::Unauthorized(format!( - "Ceremony activation rejected: duplicate share owners detected. \ + "Ceremony activation rejected — duplicate share owners detected. \ Owners: {participants:?}" ))); } + // Verify that at least one share comes from a DIFFERENT CO (dual-control). + // This prevents the assembling user from self-activating by creating all shares alone. if !participants.iter().any(|p| p.as_str() != user.as_str()) { kms_bail!(KmsError::Unauthorized( - "Ceremony activation rejected: at least one share must come from a different party." + "Ceremony activation rejected — at least one share must come from a different \ + Crypto Officer (NIST SP 800-57 Part 2 Rev 1 §4.6 dual control)." .to_owned() )); } @@ -542,7 +547,7 @@ pub(crate) async fn perform_crypto_officer_ceremony_activation( for participant in participants { if !co_cfg.users.iter().any(|u| u == participant) { kms_bail!(KmsError::Unauthorized(format!( - "Ceremony activation rejected: share owner '{participant}' is not in \ + "Ceremony activation rejected — share owner '{participant}' is not in \ `crypto_officer_users`" ))); } diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index b0ef5fc871..17938d0996 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -911,6 +911,7 @@ mod tests { "owner".to_owned(), State::PreActive, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::Active); @@ -931,6 +932,7 @@ mod tests { "owner".to_owned(), State::PreActive, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::PreActive); @@ -950,6 +952,7 @@ mod tests { "owner".to_owned(), State::PreActive, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::PreActive); @@ -995,6 +998,7 @@ mod tests { "owner".to_owned(), State::Active, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::Active); @@ -1014,6 +1018,7 @@ mod tests { "owner".to_owned(), State::Active, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::Deactivated); @@ -1034,6 +1039,7 @@ mod tests { "owner".to_owned(), State::Active, attrs, + String::new(), ); assert_eq!(owm.effective_state(), State::Active); diff --git a/crate/server/src/core/operations/rekey/symmetric/hsm.rs b/crate/server/src/core/operations/rekey/symmetric/hsm.rs index b4e9d926e2..9e4c62910c 100644 --- a/crate/server/src/core/operations/rekey/symmetric/hsm.rs +++ b/crate/server/src/core/operations/rekey/symmetric/hsm.rs @@ -194,6 +194,7 @@ impl KMS { &old_owm.object().clone(), old_attrs, &std::collections::HashSet::new(), + "", ) .await { diff --git a/crate/server/src/core/operations/revoke.rs b/crate/server/src/core/operations/revoke.rs index 244a350d24..9d8de012f3 100644 --- a/crate/server/src/core/operations/revoke.rs +++ b/crate/server/src/core/operations/revoke.rs @@ -220,12 +220,6 @@ pub(crate) async fn recursively_revoke_key( // public URL, immediately refresh the CRL so the CDP endpoint serves // an up-to-date list without requiring a manual generate-crl call. // Errors here must never fail the Revoke operation. - // - // Pass the actual revoking user, not `default_username`: the revoking - // user already proved they can access the CA chain (they own or have - // Revoke rights on the cert), so they can also read the CA cert and its - // private key to sign the CRL. Using `default_username` caused a silent - // permission failure because that user does not own the CA objects. if kms.params.kms_public_url.is_some() { if let Some(issuer_id) = issuer_id { trigger_crl_regeneration(kms, &issuer_id, user).await; @@ -409,23 +403,30 @@ fn extract_serial_hex_for_ocsp_cache(object: &Object) -> Option { /// Trigger CRL regeneration for `issuer_id` after a certificate revocation. /// /// CRL content is public information (RFC 5280 §3) so no special role is required. -/// Uses the `revoking_user` identity — the user who just performed the Revoke — because -/// they have already proven they can access the CA chain (owner or explicit `Revoke` -/// grant), and therefore have the necessary permissions to read the CA certificate and -/// its private key for CRL signing. Using `default_username` caused a silent -/// permission failure when the CA objects were owned by a different user (e.g., CO). +/// The signer is the first active Crypto Officer when one exists, because +/// `generate_crl` gates the `find_all` bypass on the CO role whenever +/// `crypto_officer.users` is configured. When no CO is configured (or none has +/// completed the ceremony) the `revoking_user` identity is used instead: that user +/// has already proven they can access the CA chain (owner or explicit `Revoke` +/// grant), so they can also read the CA certificate and its private key to sign the +/// CRL. Using `default_username` caused a silent permission failure when the CA +/// objects were owned by a different user. /// /// Errors are logged at `warn` level and never propagated — this must not fail /// the parent `Revoke` operation. async fn trigger_crl_regeneration(kms: &KMS, issuer_id: &str, revoking_user: &UserId) { + let signer = match kms.find_active_co().await { + Ok(Some(co)) => co, + _ => revoking_user.clone(), + }; + info!( issuer_id = issuer_id, "Auto-CRL: triggered CRL regeneration for issuer '{issuer_id}' after certificate revocation" ); if let Err(e) = - crate::core::operations::generate_crl::generate_crl(kms, issuer_id, None, revoking_user) - .await + crate::core::operations::generate_crl::generate_crl(kms, issuer_id, None, &signer).await { warn!( issuer_id = issuer_id, diff --git a/crate/server/src/core/retrieve_object_utils.rs b/crate/server/src/core/retrieve_object_utils.rs index 2f39029545..a5a08a81b4 100644 --- a/crate/server/src/core/retrieve_object_utils.rs +++ b/crate/server/src/core/retrieve_object_utils.rs @@ -9,7 +9,11 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{trace, warn}; use crate::{ - core::{KMS, uid_utils::ObjectHandle}, + core::{ + KMS, + opa::{OpaInput, OpaMode, get_opa_user_context}, + uid_utils::ObjectHandle, + }, error::KmsError, middlewares::UserId, result::KResult, @@ -256,6 +260,47 @@ pub(crate) async fn retrieve_object_for_operation( )) } +/// Build the OPA input document from the current request context. +/// +/// Fields that require the authentication server integration (roles, `user_domain`) +/// are populated from the JWT claims extracted by the middleware. +fn build_opa_input( + user: &str, + roles: &[String], + user_domain: Option<&str>, + owm: Option<&ObjectWithMetadata>, + operation_type: KmipOperation, +) -> OpaInput { + let (object_uid, object_domain, is_owner) = owm.map_or_else( + || { + ( + // Object-less operations (Create, Locate, …): use wildcard UID and derive the + // object domain from the caller's domain so that same_domain rules pass. + "*".to_owned(), + user_domain.unwrap_or_default().to_owned(), + false, + ) + }, + |obj| { + ( + obj.id().to_owned(), + obj.domain().to_owned(), + user == obj.owner(), + ) + }, + ); + + OpaInput { + user: user.to_owned(), + user_domain: user_domain.unwrap_or_default().to_owned(), + roles: roles.to_vec(), + operation: operation_type.to_string(), + object_uid, + object_domain, + is_owner, + } +} + /// Check if a user has permission to perform an operation on an object. /// If the user is the owner of the object, it will always return true. /// For non-HSM objects, having the `Get` permission implies all other operations. @@ -274,6 +319,103 @@ pub(crate) async fn user_has_permission( operation_type: &KmipOperation, kms: &KMS, ) -> KResult { + // ── OPA evaluation (Phase 8, Step 20) ─────────────────────────────────── + if let Some(ref opa_client) = kms.opa_client { + let mode = kms + .params + .opa_params + .as_ref() + .map_or(OpaMode::Disabled, |p| p.mode); + + match mode { + OpaMode::Disabled => { /* fall through to legacy logic */ } + OpaMode::Exclusive => { + let opa_ctx = get_opa_user_context(); + let input = build_opa_input( + user, + &opa_ctx.roles, + opa_ctx.domain.as_deref(), + owm, + *operation_type, + ); + let allowed = opa_client.query(&input).await.unwrap_or(false); + trace!( + "OPA exclusive decision for user={} op={} obj={}: {}", + user, operation_type, input.object_uid, allowed + ); + return Ok(allowed); + } + OpaMode::Enforcing => { + // ── Native KMS CO bypass ──────────────────────────────────────────── + // Users listed in `crypto_officer_users` bypass OPA Gate 1 in + // enforcing mode regardless of whether they have completed the ceremony. + // + // Rationale: OPA Gate 1 enforces JWT role/domain policy for external + // users. CO candidates are KMS-native — enrolled via server TOML config, + // not via JWT — and therefore operate outside OPA's role model. + // Requiring them to pass OPA Gate 1 creates a chicken-and-egg deadlock + // during the ceremony: candidates must Get peer shares to call + // JoinSplitKey, but `is_crypto_officer()` returns `false` until + // the ceremony completes. + // + // The bypass applies to BOTH: + // a) activated COs (`is_crypto_officer()` = true) + // b) ceremony candidates listed in `co_users` (not yet activated) + // + // Both groups fall through to the legacy KMS gate, which enforces + // ownership and explicit DB grant checks — so bypassing OPA Gate 1 + // does NOT grant unconditional access. + // + // HSM keys are excluded: their access model is separate and requires + // explicit HSM-admin grants. + let object_id = owm.map_or("*", ObjectWithMetadata::id); + let is_native_co = !ObjectHandle::from(object_id).is_hsm() + && (kms.is_crypto_officer(user).await? + || kms + .params + .crypto_officer + .users + .iter() + .any(|u| u == user.as_str())); + if !is_native_co { + // ── OPA Gate 1 ──────────────────────────────────────────────────── + let opa_ctx = get_opa_user_context(); + let input = build_opa_input( + user, + &opa_ctx.roles, + opa_ctx.domain.as_deref(), + owm, + *operation_type, + ); + let allowed = opa_client.query(&input).await.unwrap_or(false); + trace!( + "OPA enforcing decision for user={} op={} obj={}: {}", + user, operation_type, input.object_uid, allowed + ); + if !allowed { + return Ok(false); + } + // OPA approved. In enforcing mode OPA is the authoritative + // role/domain policy engine: it already evaluated `is_owner`, + // `same_domain`, and the role hierarchy against the operation. + // Trust this decision and return immediately for non-HSM objects + // rather than re-evaluating ownership/grants in the KMS legacy gate, + // which would deny valid role-based access that OPA explicitly allowed + // (e.g. CryptoOfficer reading GetAttributes on a peer's key). + // HSM-backed keys still fall through to the HSM-admin / per-HSM-grant + // check because their access model is independent of the KMIP + // object-grant model and OPA does not evaluate HSM admin status. + let is_hsm = owm.is_some_and(|o| ObjectHandle::from(o.id()).is_hsm()); + if !is_hsm { + return Ok(true); + } + } + // Native CO: fall through to the legacy KMS gate below. + } + } + } + + // ── Legacy KMS permission logic ───────────────────────────────────────── let id = match owm { Some(object) if user == object.owner() => return Ok(true), Some(object) => object.id(), @@ -331,3 +473,181 @@ pub(crate) async fn user_has_permission( Ok(permissions.contains(operation_type) || permissions.contains(&KmipOperation::Get)) } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use cosmian_kms_server_database::reexport::{ + cosmian_kmip::{ + kmip_0::kmip_types::State, + kmip_2_1::{ + KmipOperation, + kmip_attributes::Attributes, + kmip_objects::{Object, OpaqueObject}, + kmip_types::OpaqueDataType, + }, + }, + cosmian_kms_interfaces::ObjectWithMetadata, + }; + + use super::build_opa_input; + + // ── Helper ────────────────────────────────────────────────────────────── + + /// Build a minimal `ObjectWithMetadata` for testing. + /// + /// Uses `OpaqueObject` (the lightest available `Object` variant) to avoid + /// constructing crypto key material in unit tests. + fn make_owm(uid: &str, owner: &str, domain: &str) -> ObjectWithMetadata { + ObjectWithMetadata::new( + uid.to_owned(), + Object::OpaqueObject(OpaqueObject { + opaque_data_type: OpaqueDataType::Unknown, + opaque_data_value: uid.as_bytes().to_vec(), + }), + owner.to_owned(), + State::Active, + Attributes::default(), + domain.to_owned(), + ) + } + + // ── Object-less operations (owm = None) ────────────────────────────────── + + /// When `owm` is `None` (object-less operation such as `Create`): + /// - `object_uid` must be `"*"` (wildcard UID) + /// - `object_domain` must equal `user_domain` so `same_domain` passes + /// - `is_owner` must be `false` + #[test] + fn test_build_opa_input_objectless_uses_wildcard_uid() { + let input = build_opa_input( + "alice@acme.com", + &["CryptoOfficer".to_owned()], + Some("acme.com"), + None, + KmipOperation::Create, + ); + assert_eq!(input.object_uid, "*", "object-less op must use '*' UID"); + assert_eq!( + input.object_domain, "acme.com", + "object_domain must equal user_domain for object-less ops" + ); + assert!( + !input.is_owner, + "is_owner must be false for object-less ops" + ); + } + + /// When no user domain is supplied (`None`) for an object-less operation, + /// both `user_domain` and `object_domain` default to empty string so the + /// `same_domain` check in Rego still passes (both are `""`). + #[test] + fn test_build_opa_input_objectless_no_domain_defaults_to_empty_string() { + let input = build_opa_input("alice@acme.com", &[], None, None, KmipOperation::Create); + assert_eq!(input.user_domain, ""); + assert_eq!( + input.object_domain, "", + "object_domain must be '' when user_domain is None" + ); + assert_eq!(input.object_uid, "*"); + } + + // ── Operations on existing objects ────────────────────────────────────── + + /// When `user == obj.owner()`, `is_owner` must be `true` and the object + /// UID and domain must be taken from the object (not the wildcard). + #[test] + fn test_build_opa_input_owner_sets_is_owner_true() { + let owm = make_owm("uid-001", "alice@acme.com", "acme.com"); + let input = build_opa_input( + "alice@acme.com", + &["CryptoOfficer".to_owned()], + Some("acme.com"), + Some(&owm), + KmipOperation::Get, + ); + assert!(input.is_owner, "caller must be recognised as owner"); + assert_eq!(input.object_uid, "uid-001"); + assert_eq!(input.object_domain, "acme.com"); + } + + /// When `user != obj.owner()`, `is_owner` must be `false`. + #[test] + fn test_build_opa_input_non_owner_sets_is_owner_false() { + let owm = make_owm("uid-002", "alice@acme.com", "acme.com"); + let input = build_opa_input( + "bob@acme.com", + &["CryptoOfficer".to_owned()], + Some("acme.com"), + Some(&owm), + KmipOperation::Get, + ); + assert!(!input.is_owner, "non-owner must have is_owner=false"); + assert_eq!(input.object_uid, "uid-002"); + } + + /// Object domain is read from the stored object metadata, not from the + /// user domain. This is the cross-domain isolation invariant. + #[test] + fn test_build_opa_input_object_domain_comes_from_owm() { + let owm = make_owm("uid-003", "alice@other.com", "other.com"); + let input = build_opa_input( + "bob@acme.com", + &["CryptoOfficer".to_owned()], + Some("acme.com"), + Some(&owm), + KmipOperation::Get, + ); + assert_eq!(input.user_domain, "acme.com"); + assert_eq!( + input.object_domain, "other.com", + "object_domain must come from the stored object" + ); + } + + // ── Operation name format ──────────────────────────────────────────────── + + /// `KmipOperation::to_string()` must produce the lowercase `snake_case` names + /// that the `kms.rego` policy uses for operation set membership tests. + #[test] + fn test_build_opa_input_operation_is_lowercase_snake_case() { + let cases = [ + (KmipOperation::Create, "create"), + (KmipOperation::Get, "get"), + (KmipOperation::GetAttributes, "get_attributes"), + (KmipOperation::Destroy, "destroy"), + (KmipOperation::Locate, "locate"), + ]; + for (op, expected) in cases { + let input = build_opa_input("u", &[], None, None, op); + assert_eq!( + input.operation, expected, + "KmipOperation::{op:?} must serialize to '{expected}'" + ); + } + } + + // ── Roles and user identity passthrough ────────────────────────────────── + + /// Roles are passed through unchanged; they are never modified by + /// `build_opa_input` (KMS is role-vocabulary-agnostic). + #[test] + fn test_build_opa_input_roles_passed_through_unchanged() { + let roles = vec!["CryptoOfficer".to_owned(), "Auditor".to_owned()]; + let input = build_opa_input("u", &roles, None, None, KmipOperation::Create); + assert_eq!(input.roles, roles); + } + + /// The user identity is copied verbatim to `input.user`. + #[test] + fn test_build_opa_input_user_identity_is_preserved() { + let input = build_opa_input( + "alice@tenant.example", + &[], + None, + None, + KmipOperation::Create, + ); + assert_eq!(input.user, "alice@tenant.example"); + } +} diff --git a/crate/server/src/main.rs b/crate/server/src/main.rs index 65977db3aa..408ef18b38 100644 --- a/crate/server/src/main.rs +++ b/crate/server/src/main.rs @@ -236,8 +236,8 @@ mod tests { config::{ AuthVerifierConfig, AzureEkmConfig, ClapConfig, CrlConfig, GoogleCseConfig, HttpConfig, IdpAuthConfig, JwksEndpointConfig, KmipPolicyConfig, LoggingConfig, MainDBConfig, - OidcConfig, ProxyConfig, RolesConfig, SocketServerConfig, TlsConfig, UiConfig, - WorkspaceConfig, + OidcConfig, OpaConfig, ProxyConfig, RolesConfig, SocketServerConfig, TlsConfig, + UiConfig, WorkspaceConfig, }, routes::aws_xks::AwsXksConfig, }; @@ -374,6 +374,7 @@ mod tests { roles: RolesConfig::default(), print_default_config: false, secret_backends: cosmian_kms_server::config::SecretBackendConfig::default(), + opa: OpaConfig::default(), auto_rotation_check_interval_secs: 0, keyset_warn_depth: 5, vault: cosmian_kms_server::config::VaultConfig::default(), @@ -479,6 +480,9 @@ aws_xks_service = "kms-xks-proxy" aws_xks_sigv4_access_key_id = "AKIAIOSFODNN7EXAMPLE" aws_xks_sigv4_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +[opa] +opa_mode = "disabled" + [kmip.allowlists] [jwks_endpoint] diff --git a/crate/server/src/middlewares/api_token/api_token_middleware.rs b/crate/server/src/middlewares/api_token/api_token_middleware.rs index ded7857a6d..1802a60cf4 100644 --- a/crate/server/src/middlewares/api_token/api_token_middleware.rs +++ b/crate/server/src/middlewares/api_token/api_token_middleware.rs @@ -47,6 +47,8 @@ where req.extensions_mut().insert(AuthenticatedUser { username: UserId::from(kms_server.params.default_username.as_str()), auth_method: AuthMethod::ApiToken, + roles: Vec::new(), + domain: None, }); } Err(e) => { diff --git a/crate/server/src/middlewares/auth_verifier/mod.rs b/crate/server/src/middlewares/auth_verifier/mod.rs index 9db21dcca9..6f446342b7 100644 --- a/crate/server/src/middlewares/auth_verifier/mod.rs +++ b/crate/server/src/middlewares/auth_verifier/mod.rs @@ -2,4 +2,4 @@ mod middleware; mod token; pub(crate) use middleware::AuthVerifier; -pub(crate) use token::verify_auth_verifier_jwt_subject; +pub(crate) use token::verify_auth_verifier_jwt; diff --git a/crate/server/src/middlewares/auth_verifier/token.rs b/crate/server/src/middlewares/auth_verifier/token.rs index ee1fb4256a..7f5f58f55c 100644 --- a/crate/server/src/middlewares/auth_verifier/token.rs +++ b/crate/server/src/middlewares/auth_verifier/token.rs @@ -48,19 +48,34 @@ const ALLOWED_ALGORITHMS: &[Algorithm] = &[ Algorithm::PS512, ]; -/// Claims extracted from a Auth Verifier server JWT. +/// Claims extracted from a Cosmian Auth Verifier JWT. +/// +/// The auth server includes the RBAC `roles` (RFC 9068 private claim) and +/// the realm identifier in `as_rid` so that OPA can enforce domain-scoped +/// policies without an additional lookup. #[derive(Debug, Deserialize)] -struct AuthVerifierClaims { - /// Subject — used as the KMS user identity. +pub(crate) struct AuthVerifierClaims { + /// Subject — used as the KMS user identity (username / email). pub sub: String, + /// RBAC roles emitted by the auth server (RFC 9068 `roles` private claim). + /// Defaults to an empty list for tokens that predate role support. + #[serde(default)] + pub roles: Vec, + /// Realm / domain the authenticated user belongs to. + /// + /// The auth server sets this as `as_rid` (realm ID). The legacy alias + /// `as_domain` is also accepted for tokens issued before the field was + /// renamed, matching the same alias on [`UserClaim`]. + #[serde(alias = "as_domain", alias = "as_rid")] + pub domain: Option, } /// Core authentication handler for Auth Verifier server tokens. /// -/// Extracts the bearer token from the `Authorization` header and validates it -/// against every key in the JWKS (since these tokens carry no `kid`). -/// -/// Returns the authenticated username (`sub`) on success, or an error. +/// Extracts the bearer token from the `Authorization` header, validates it +/// against every key in the JWKS (Cosmian tokens carry no `kid`), and +/// populates [`AuthenticatedUser`] with the full claims — including `roles` +/// and `domain` — so OPA can evaluate role-based and domain-scoped policies. pub(super) async fn handle_auth_verifier( jwks_manager: &Arc, req: &ServiceRequest, @@ -68,38 +83,37 @@ pub(super) async fn handle_auth_verifier( let token = extract_bearer_token(req) .map_err(|e| KmsError::Unauthorized(format!("Auth Verifier: {e}")))?; - let username = verify_auth_verifier_jwt_subject(jwks_manager, token).await?; + let claims = verify_auth_verifier_jwt(jwks_manager, token).await?; Ok(AuthenticatedUser { - username: username.into(), + username: claims.sub.into(), auth_method: AuthMethod::AuthVerifierJwt, + domain: claims.domain, + roles: claims.roles, }) } -/// Validate a Auth Verifier server JWT and return its `sub` claim (the -/// authenticated username). +/// Validate a Cosmian Auth Verifier JWT and return its full claims. /// -/// Shared between the bearer-token `AuthVerifier` middleware -/// (`handle_auth_verifier`) and the UI's BFF login proxy -/// (`crate::routes::ui_auth::login_as`), which validates the JWT the Cosmian -/// authentication server returns via `Set-Cookie: _ea_=` before storing -/// the resulting username in the actix session. Keeping a single -/// implementation avoids the two call sites drifting apart on trust logic. +/// Validates the signature against every public key in the JWKS (Cosmian +/// tokens carry no `kid`). Returns all claims — `sub`, `roles`, and +/// `domain` (`as_rid` / `as_domain`) — so callers can populate +/// [`AuthenticatedUser`] or store them in a session without re-parsing. /// -/// In test / insecure builds the signature check is skipped (same behaviour -/// as the existing `JwtAuth` middleware). +/// In test / insecure builds the signature check is skipped; only the +/// claim structure is decoded (same behaviour as [`JwtAuth`]). #[cfg_attr(any(test, feature = "insecure"), allow(unused_variables))] #[cfg_attr(any(test, feature = "insecure"), allow(clippy::unused_async))] -pub(crate) async fn verify_auth_verifier_jwt_subject( +pub(crate) async fn verify_auth_verifier_jwt( jwks_manager: &Arc, token: &str, -) -> KResult { +) -> KResult { // In test/insecure builds skip signature validation — decode only. #[cfg(any(test, feature = "insecure"))] { let token_data = dangerous::insecure_decode::(token).map_err(|e| { KmsError::Unauthorized(format!("Auth Verifier: cannot decode token: {e}")) })?; - Ok(token_data.claims.sub) + Ok(token_data.claims) } // Production: full validation. @@ -150,7 +164,7 @@ pub(crate) async fn verify_auth_verifier_jwt_subject( match decode::(token, &decoding_key, &validation) { Ok(data) => { - return Ok(data.claims.sub); + return Ok(data.claims); } Err(e) => { last_error = Some(format!("{e}")); @@ -165,3 +179,236 @@ pub(crate) async fn verify_auth_verifier_jwt_subject( ))) } } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use std::{collections::HashMap, sync::RwLock}; + + use actix_web::dev::ServiceRequest; + + use super::*; + + /// Craft a minimal JWT with `HS256` header. + /// + /// In test/insecure builds `insecure_decode` is used, which skips signature + /// validation but still requires a known algorithm in the header. `HS256` is + /// the smallest valid choice. The signature segment is left as an empty dummy. + fn make_test_jwt(sub: &str, roles: &[&str], domain: Option<&str>) -> String { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#); + + let roles_json: String = { + let parts: Vec = roles.iter().map(|r| format!("\"{r}\"")).collect(); + format!("[{}]", parts.join(",")) + }; + let domain_json = domain.map_or_else(|| "null".to_owned(), |d| format!("\"{d}\"")); + let payload_str = format!( + r#"{{"sub":"{sub}","roles":{roles_json},"as_rid":{domain_json},"exp":9999999999}}"# + ); + let payload = URL_SAFE_NO_PAD.encode(payload_str); + // Signature is ignored by `insecure_decode`; use a single underscore as placeholder. + format!("{header}.{payload}._") + } + + /// Build a no-op JWKS manager directly (no async, no Result). + /// + /// In test builds `insecure_decode` is used so the JWKS is never consulted; + /// the empty struct is a valid stand-in. Constructing it synchronously avoids + /// `expect_used` / `panic_in_result_fn` lints. + fn empty_jwks() -> Arc { + Arc::new(JwksManager { + uris: vec![], + jwks: RwLock::new(HashMap::new()), + last_update: RwLock::new(None), + last_force_refresh: RwLock::new(None), + proxy_params: None, + accept_invalid_certs: false, + }) + } + + /// Build a `ServiceRequest` that carries a bare JWT in the `Authorization: Bearer` header. + /// + /// `handle_auth_verifier` uses `extract_bearer_token` which reads this header directly, + /// so all full-pipeline tests below use this helper. + fn srv_req_with_bearer(token: &str) -> ServiceRequest { + actix_web::test::TestRequest::get() + .insert_header(("Authorization", format!("Bearer {token}"))) + .to_srv_request() + } + + /// Roles and domain are extracted correctly for a `SuperAdmin` JWT. + #[tokio::test] + async fn test_verify_auth_verifier_jwt_extracts_sub_roles_domain() { + let token = make_test_jwt("super.admin@acme.com", &["SuperAdmin"], Some("acme.com")); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.sub, "super.admin@acme.com"); + assert_eq!(claims.roles, vec!["SuperAdmin"]); + assert_eq!(claims.domain.as_deref(), Some("acme.com")); + } + } + + /// A `CryptoOfficer` JWT carries the correct role and domain. + #[tokio::test] + async fn test_verify_auth_verifier_jwt_crypto_officer_role() { + let token = make_test_jwt("officer@acme.com", &["CryptoOfficer"], Some("acme.com")); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.roles, vec!["CryptoOfficer"]); + assert_eq!(claims.domain.as_deref(), Some("acme.com")); + } + } + + /// Tokens without roles or domain must still parse (legacy format compatibility). + #[tokio::test] + async fn test_verify_auth_verifier_jwt_empty_roles_no_domain() { + let token = make_test_jwt("user@acme.com", &[], None); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.sub, "user@acme.com"); + assert!(claims.roles.is_empty()); + assert!(claims.domain.is_none()); + } + } + + /// The `as_domain` alias (pre-rename) is accepted for backward compatibility. + #[tokio::test] + async fn test_verify_auth_verifier_jwt_as_domain_alias() { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode( + r#"{"sub":"officer@acme.com","roles":["CryptoOfficer"],"as_domain":"acme.com","exp":9999999999}"#, + ); + let token = format!("{header}.{payload}._"); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.domain.as_deref(), Some("acme.com")); + assert_eq!(claims.roles, vec!["CryptoOfficer"]); + } + } + + /// `verify_auth_verifier_jwt` correctly propagates errors for a malformed token. + #[tokio::test] + async fn test_verify_auth_verifier_jwt_subject_wrapper() { + let token = make_test_jwt("admin@acme.com", &["SuperAdmin"], Some("acme.com")); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.sub, "admin@acme.com"); + } + } + + /// Multiple roles in the same JWT are all preserved. + #[tokio::test] + async fn test_verify_auth_verifier_jwt_multiple_roles() { + let token = make_test_jwt( + "multi@acme.com", + &["CryptoOfficer", "Auditor"], + Some("acme.com"), + ); + let result = verify_auth_verifier_jwt(&empty_jwks(), &token).await; + assert!(result.is_ok(), "JWT decode must succeed: {result:?}"); + if let Ok(claims) = result { + assert_eq!(claims.roles, vec!["CryptoOfficer", "Auditor"]); + } + } + + // ── Full-pipeline tests for `handle_auth_verifier` ───────────────────────── + // + // The tests above verify only `verify_auth_verifier_jwt` (claim parsing). + // The tests below exercise the full middleware pipeline: + // + // `handle_auth_verifier` + // → `extract_bearer_token` (reads `Authorization: Bearer` header) + // → `verify_auth_verifier_jwt` (decodes + validates claims) + // → constructs `AuthenticatedUser{username, roles, domain}` + // + // They are the definitive proof that `domain` and `roles` survive all the + // way from the JWT claim through to the struct that OPA and the permission + // checks consume. + + /// `handle_auth_verifier` propagates `domain` from `as_rid` through to + /// `AuthenticatedUser.domain` without dropping or mangling the value. + #[tokio::test] + async fn test_handle_auth_verifier_domain_propagated_to_authenticated_user() { + let token = make_test_jwt("officer@acme.com", &["CryptoOfficer"], Some("acme.com")); + let req = srv_req_with_bearer(&token); + let result = handle_auth_verifier(&empty_jwks(), &req).await; + assert!( + result.is_ok(), + "handle_auth_verifier must succeed: {result:?}" + ); + let user = result.expect("already checked is_ok"); + assert_eq!(user.domain.as_deref(), Some("acme.com")); + } + + /// `handle_auth_verifier` propagates `roles` through to + /// `AuthenticatedUser.roles` without dropping any entry. + #[tokio::test] + async fn test_handle_auth_verifier_roles_propagated_to_authenticated_user() { + let token = make_test_jwt("officer@acme.com", &["CryptoOfficer"], Some("acme.com")); + let req = srv_req_with_bearer(&token); + let result = handle_auth_verifier(&empty_jwks(), &req).await; + assert!( + result.is_ok(), + "handle_auth_verifier must succeed: {result:?}" + ); + let user = result.expect("already checked is_ok"); + assert_eq!(user.roles, vec!["CryptoOfficer"]); + } + + /// `handle_auth_verifier` uses the `sub` claim as `AuthenticatedUser.username`. + /// + /// The Cosmian auth server puts the username in `sub`, not `email`. + #[tokio::test] + async fn test_handle_auth_verifier_sub_becomes_username() { + let token = make_test_jwt("alice@acme.com", &["Auditor"], Some("acme.com")); + let req = srv_req_with_bearer(&token); + let result = handle_auth_verifier(&empty_jwks(), &req).await; + assert!( + result.is_ok(), + "handle_auth_verifier must succeed: {result:?}" + ); + let user = result.expect("already checked is_ok"); + assert_eq!(user.username.as_ref(), "alice@acme.com"); + } + + /// A missing `Authorization` header must cause `handle_auth_verifier` to + /// return an error rather than proceeding with an empty identity. + #[tokio::test] + async fn test_handle_auth_verifier_missing_bearer_returns_error() { + let req = actix_web::test::TestRequest::get().to_srv_request(); + let result = handle_auth_verifier(&empty_jwks(), &req).await; + assert!( + result.is_err(), + "must fail when no Authorization header is present" + ); + } + + /// When the JWT carries no `as_rid` / `as_domain` claim, `domain` must be + /// `None` on the resulting `AuthenticatedUser` — not a spurious empty string + /// or an error. + #[tokio::test] + async fn test_handle_auth_verifier_domain_none_when_claim_absent() { + let token = make_test_jwt("user@acme.com", &["User"], None); + let req = srv_req_with_bearer(&token); + let result = handle_auth_verifier(&empty_jwks(), &req).await; + assert!( + result.is_ok(), + "handle_auth_verifier must succeed: {result:?}" + ); + let user = result.expect("already checked is_ok"); + assert!( + user.domain.is_none(), + "domain must be None when the claim is absent, got {:?}", + user.domain + ); + } +} diff --git a/crate/server/src/middlewares/ensure_auth.rs b/crate/server/src/middlewares/ensure_auth.rs index dfad23a01a..719b7ddd96 100644 --- a/crate/server/src/middlewares/ensure_auth.rs +++ b/crate/server/src/middlewares/ensure_auth.rs @@ -57,6 +57,8 @@ where // No authentication configured — inject the default username. req.extensions_mut().insert(AuthenticatedUser { username: UserId::from(kms_server.params.default_username.as_str()), + roles: Vec::new(), + domain: None, auth_method: AuthMethod::DefaultUser, }); next.call(req) diff --git a/crate/server/src/middlewares/jwt/jwt_config.rs b/crate/server/src/middlewares/jwt/jwt_config.rs index 7114f7cdee..74fa777afd 100644 --- a/crate/server/src/middlewares/jwt/jwt_config.rs +++ b/crate/server/src/middlewares/jwt/jwt_config.rs @@ -106,12 +106,19 @@ pub(crate) struct UserClaim { pub email: Option, pub iss: Option, pub sub: Option, - #[serde(deserialize_with = "deserialize_aud")] + #[serde(default, deserialize_with = "deserialize_aud")] pub aud: Option>, pub iat: Option, pub exp: Option, pub nbf: Option, pub jti: Option, + // OPA RBAC: roles claim emitted by auth server + pub roles: Option>, + // OPA RBAC: domain claim — read from `as_rid` (auth server realm ID). + // `as_domain` is accepted as a legacy alias for tokens issued before the + // domain field was removed from AuthPrivateClaims. + #[serde(alias = "as_domain", alias = "as_rid")] + pub domain: Option, // Google CSE pub role: Option, // Google CSE diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 360fec54b9..143994073a 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -1,13 +1,12 @@ //! JWT Authentication Middleware //! //! This module handles JWT-based authentication for the KMS server. -//! It extracts and validates JWT tokens from the Authorization header -//! or from an Identity service, then processes the claims to authenticate users. +//! It extracts and validates JWT tokens from the `Authorization: Bearer` header, +//! then processes the claims to authenticate users. use std::sync::Arc; -use actix_identity::Identity; -use actix_web::{FromRequest, dev::ServiceRequest, http::header}; +use actix_web::{dev::ServiceRequest, http::header}; use cosmian_logger::{debug, trace, warn}; use super::UserClaim; @@ -47,15 +46,16 @@ fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result>, @@ -63,18 +63,13 @@ pub(super) async fn handle_jwt( ) -> KResult { trace!("JWT Authentication..."); - // Extract identity from either the Identity service or the Authorization header - let identity = Identity::extract(req.request()) - .into_inner() - .map_or_else( - |_| { - // If Identity extraction fails, try the Authorization header - req.headers() - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok().map(str::to_owned)) - }, - |identity| identity.id().ok(), - ) + // Read the raw `Authorization` header value (e.g. `"Bearer eyJ…"`). + // `decode_bearer_header` called by `extract_user_claim` will strip the + // `"Bearer "` prefix and decode the token payload. + let identity = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok().map(str::to_owned)) .unwrap_or_default(); // Try to extract and validate the user claim @@ -94,23 +89,31 @@ pub(super) async fn handle_jwt( } // Process the validation result and extract the email claim - match private_claim.map(|user_claim| user_claim.email) { - Ok(Some(email)) => { - // Authentication successful with valid email - debug!("JWT Access granted to {email}!"); - Ok(AuthenticatedUser { - username: UserId::from(email), - auth_method: AuthMethod::OidcJwt, - }) - } - Ok(None) => { - // JWT is valid but missing the required email claim — log as WARN for audit trail - warn!( - "{:?} {} 401 unauthorized, no email in JWT", - req.method(), - req.path() - ); - Err(KmsError::InvalidRequest("No email in JWT".to_owned())) + match private_claim { + Ok(user_claim) => { + // Accept `email` (Google/Auth0 style) or fall back to `sub` (standard JWT subject, + // used by the Cosmian auth server and other issuer-agnostic IdPs). + let username = user_claim.email.or(user_claim.sub); + if let Some(username) = username { + // Authentication successful + debug!("JWT Access granted to {username}!"); + Ok(AuthenticatedUser { + username: UserId::from(username), + auth_method: AuthMethod::OidcJwt, + roles: user_claim.roles.unwrap_or_default(), + domain: user_claim.domain, + }) + } else { + // JWT is valid but missing both email and sub claims + warn!( + "{:?} {} 401 unauthorized, no email or sub in JWT", + req.method(), + req.path() + ); + Err(KmsError::InvalidRequest( + "No email or sub in JWT".to_owned(), + )) + } } Err(jwt_log_errors) => { // JWT validation failed — log at WARN so auth failures appear in production logs @@ -126,3 +129,159 @@ pub(super) async fn handle_jwt( } } } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use std::{collections::HashMap, sync::RwLock}; + + use actix_web::dev::ServiceRequest; + + use super::*; + use crate::middlewares::jwt::jwks::JwksManager; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /// Build a minimal, unsigned JWT carrying the given OIDC-style claims. + /// + /// Uses `HS256` in the header because `insecure_decode` (active in test + /// builds) requires a recognised algorithm in the header but ignores the + /// signature entirely. + /// + /// Fields match [`UserClaim`]: + /// - `email` / `sub` for username resolution (`email` wins if both present) + /// - `roles` for OPA RBAC + /// - `as_rid` for domain (alias accepted: `as_domain`) + fn make_test_jwt( + email: Option<&str>, + sub: &str, + roles: &[&str], + domain: Option<&str>, + ) -> String { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#); + let email_json = email.map_or_else(|| "null".to_owned(), |e| format!("\"{e}\"")); + let roles_json = { + let parts: Vec = roles.iter().map(|r| format!("\"{r}\"")).collect(); + format!("[{}]", parts.join(",")) + }; + let domain_json = domain.map_or_else(|| "null".to_owned(), |d| format!("\"{d}\"")); + let payload = URL_SAFE_NO_PAD.encode(format!( + r#"{{"email":{email_json},"sub":"{sub}","roles":{roles_json},"as_rid":{domain_json},"exp":9999999999}}"# + )); + // Signature is ignored by `insecure_decode`; a single underscore is a valid placeholder. + format!("{header}.{payload}._") + } + + /// Build a [`JwtConfig`] backed by an in-memory no-op JWKS. + /// + /// In test builds `insecure_decode` is used — the JWKS is never consulted — + /// so an empty manager is a valid stand-in. + fn test_jwt_config() -> JwtConfig { + JwtConfig { + jwt_issuer_uri: "https://test.issuer.local".to_owned(), + jwt_audience: None, + jwks: Arc::new(JwksManager { + uris: vec![], + jwks: RwLock::new(HashMap::new()), + last_update: RwLock::new(None), + last_force_refresh: RwLock::new(None), + proxy_params: None, + accept_invalid_certs: false, + }), + } + } + + /// Build a [`ServiceRequest`] with an `Authorization: Bearer ` header. + /// + /// `handle_jwt` reads the identity from `actix_identity::Identity` first; in + /// tests that fails and it falls back to this header, which is the path we + /// want to exercise. + fn srv_req_with_bearer(token: &str) -> ServiceRequest { + actix_web::test::TestRequest::get() + .insert_header(("Authorization", format!("Bearer {token}"))) + .to_srv_request() + } + + // ── Tests ────────────────────────────────────────────────────────────────── + + /// `handle_jwt` propagates the `as_rid` domain claim all the way through to + /// `AuthenticatedUser.domain`. This is the critical path for OPA + /// `same_domain` checks. + #[tokio::test] + async fn test_handle_jwt_domain_propagated_to_authenticated_user() { + let token = make_test_jwt( + Some("officer@acme.com"), + "officer@acme.com", + &["CryptoOfficer"], + Some("acme.com"), + ); + let configs = Arc::new(vec![test_jwt_config()]); + let req = srv_req_with_bearer(&token); + let result = handle_jwt(configs, &req).await; + assert!(result.is_ok(), "handle_jwt must succeed: {result:?}"); + let user = result.expect("already checked is_ok"); + assert_eq!(user.domain.as_deref(), Some("acme.com")); + } + + /// `handle_jwt` propagates the `roles` claim to `AuthenticatedUser.roles`. + #[tokio::test] + async fn test_handle_jwt_roles_propagated_to_authenticated_user() { + let token = make_test_jwt( + Some("officer@acme.com"), + "officer@acme.com", + &["CryptoOfficer"], + Some("acme.com"), + ); + let configs = Arc::new(vec![test_jwt_config()]); + let req = srv_req_with_bearer(&token); + let result = handle_jwt(configs, &req).await; + assert!(result.is_ok(), "handle_jwt must succeed: {result:?}"); + let user = result.expect("already checked is_ok"); + assert_eq!(user.roles, vec!["CryptoOfficer"]); + } + + /// When the JWT carries no `email` claim, `sub` is used as the username + /// (Cosmian auth server compatibility — it only sets `sub`). + #[tokio::test] + async fn test_handle_jwt_falls_back_to_sub_when_no_email() { + let token = make_test_jwt(None, "sub-only@acme.com", &[], None); + let configs = Arc::new(vec![test_jwt_config()]); + let req = srv_req_with_bearer(&token); + let result = handle_jwt(configs, &req).await; + assert!(result.is_ok(), "handle_jwt must succeed: {result:?}"); + let user = result.expect("already checked is_ok"); + assert_eq!(user.username.as_ref(), "sub-only@acme.com"); + } + + /// When the JWT carries both `email` and `sub`, `email` wins as the username + /// (Google / Auth0 style `IdPs` set `email` as the primary identity). + #[tokio::test] + async fn test_handle_jwt_prefers_email_over_sub() { + let token = make_test_jwt(Some("alice@acme.com"), "alice-sub@acme.com", &[], None); + let configs = Arc::new(vec![test_jwt_config()]); + let req = srv_req_with_bearer(&token); + let result = handle_jwt(configs, &req).await; + assert!(result.is_ok(), "handle_jwt must succeed: {result:?}"); + let user = result.expect("already checked is_ok"); + assert_eq!(user.username.as_ref(), "alice@acme.com"); + } + + /// When the JWT carries no `as_rid` / `as_domain` claim, `domain` must be + /// `None` — not an empty string or an error. + #[tokio::test] + async fn test_handle_jwt_no_domain_when_claim_absent() { + let token = make_test_jwt(Some("user@acme.com"), "user@acme.com", &["User"], None); + let configs = Arc::new(vec![test_jwt_config()]); + let req = srv_req_with_bearer(&token); + let result = handle_jwt(configs, &req).await; + assert!(result.is_ok(), "handle_jwt must succeed: {result:?}"); + let user = result.expect("already checked is_ok"); + assert!( + user.domain.is_none(), + "domain must be None when claim is absent, got {:?}", + user.domain + ); + } +} diff --git a/crate/server/src/middlewares/mod.rs b/crate/server/src/middlewares/mod.rs index f0f235d6ad..44aa1689b0 100644 --- a/crate/server/src/middlewares/mod.rs +++ b/crate/server/src/middlewares/mod.rs @@ -6,7 +6,7 @@ mod api_token; pub(crate) use api_token::api_token_middleware; mod auth_verifier; -pub(crate) use auth_verifier::{AuthVerifier, verify_auth_verifier_jwt_subject}; +pub(crate) use auth_verifier::{AuthVerifier, verify_auth_verifier_jwt}; mod ensure_auth; pub(crate) use ensure_auth::ensure_auth_middleware; @@ -90,4 +90,9 @@ pub(crate) struct AuthenticatedUser { pub username: UserId, /// Which authentication method was used pub auth_method: AuthMethod, + /// RBAC roles from JWT (empty if not present) + pub roles: Vec, + /// Domain from JWT `as_rid` claim (realm ID from auth server). + /// Legacy tokens may carry the same value as `as_domain`. + pub domain: Option, } diff --git a/crate/server/src/middlewares/session_auth.rs b/crate/server/src/middlewares/session_auth.rs index 9c0167346c..4c860b9953 100644 --- a/crate/server/src/middlewares/session_auth.rs +++ b/crate/server/src/middlewares/session_auth.rs @@ -86,9 +86,17 @@ where match session.get::("user_id") { Ok(Some(user_id)) => { debug!("Session: authenticated user '{user_id}'"); + let roles = session + .get::>("roles") + .ok() + .flatten() + .unwrap_or_default(); + let domain = session.get::("domain").ok().flatten(); req.extensions_mut().insert(AuthenticatedUser { username: user_id.into(), auth_method: AuthMethod::Session, + domain, + roles, }); } Ok(None) => { diff --git a/crate/server/src/middlewares/spire_token.rs b/crate/server/src/middlewares/spire_token.rs index a788ab096f..73846a90a7 100644 --- a/crate/server/src/middlewares/spire_token.rs +++ b/crate/server/src/middlewares/spire_token.rs @@ -295,6 +295,8 @@ where req.extensions_mut().insert(AuthenticatedUser { username: user.entity.clone().into(), auth_method: AuthMethod::SpireToken, + domain: None, + roles: vec![], }); req.extensions_mut().insert(user); next.call(req) diff --git a/crate/server/src/middlewares/tls_auth.rs b/crate/server/src/middlewares/tls_auth.rs index 691f256982..dcad6a4f40 100644 --- a/crate/server/src/middlewares/tls_auth.rs +++ b/crate/server/src/middlewares/tls_auth.rs @@ -108,6 +108,8 @@ fn tls_auth(req: &ServiceRequest) -> KResult { Ok(AuthenticatedUser { username: UserId::from(trimmed), auth_method: AuthMethod::Mtls, + roles: Vec::new(), + domain: None, }) } Err(e) => kms_bail!("Client certificate common name is not UTF-8: {}", e), diff --git a/crate/server/src/routes/access.rs b/crate/server/src/routes/access.rs index bce88d8906..72a5ea67da 100644 --- a/crate/server/src/routes/access.rs +++ b/crate/server/src/routes/access.rs @@ -17,7 +17,7 @@ use tracing::info as trace_info; use crate::{ core::{ - KMS, operations::perform_crypto_officer_ceremony_activation, + KMS, opa::OPA_USER_CONTEXT, operations::perform_crypto_officer_ceremony_activation, retrieve_object_utils::user_has_permission, }, middlewares::UserId, @@ -161,19 +161,24 @@ pub(crate) async fn get_create_access( let _enter = span.enter(); let user = kms.get_user(&req); + let opa_ctx = kms.extract_opa_context(&req); let has_create_permission = { let co_users = &kms.params.crypto_officer.users; if co_users.is_empty() || co_users.iter().any(|u| u == user.as_str()) { true } else { - user_has_permission( - &user, - None, - &cosmian_kmip::kmip_2_1::KmipOperation::Create, - &kms, - ) - .await? + OPA_USER_CONTEXT + .scope( + opa_ctx, + user_has_permission( + &user, + None, + &cosmian_kmip::kmip_2_1::KmipOperation::Create, + &kms, + ), + ) + .await? } }; Ok(Json(CreatePermissionResponse { @@ -359,11 +364,6 @@ pub(crate) struct CeremonyActivateRequest { /// **Authorization**: caller must be listed in `crypto_officer_users`. /// /// **Ceremony mode only**: returns an error when `require_ceremony = false`. -/// -/// **Shared by CLI and UI**: both `ckms access crypto-officer ceremony activate` -/// and the Web UI (`ui/src/actions/Access/CryptoOfficerRole.tsx`) hit this -/// endpoint exclusively. Any change to the request shape or response format must -/// be verified against both callers. #[post("/access/crypto_officer/ceremony/activate")] pub(crate) async fn activate_crypto_officer_ceremony( req: HttpRequest, diff --git a/crate/server/src/routes/kmip.rs b/crate/server/src/routes/kmip.rs index 18c6942d2b..7f3519269b 100644 --- a/crate/server/src/routes/kmip.rs +++ b/crate/server/src/routes/kmip.rs @@ -28,6 +28,7 @@ use tracing::Instrument; use crate::{ core::{ KMS, + opa::OPA_USER_CONTEXT, operations::{dispatch, message}, }, error::KmsError, @@ -157,13 +158,14 @@ pub(crate) async fn kmip_2_1_json( let ttlv = serde_json::from_str::(&body)?; let user = kms.get_user(&req_http); + let opa_ctx = kms.extract_opa_context(&req_http); let auth_method = kms.get_auth_method(&req_http); debug!(target: "kmip", user = user.as_str(), ?auth_method, tag=ttlv.tag.as_str(), "POST /kmip/2_1. Request: {:?} {}", ttlv.tag.as_str(), user); - let ttlv = Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1)).await?; + let ttlv = OPA_USER_CONTEXT + .scope(opa_ctx, Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1))) + .await?; - // Pre-allocate buffer to avoid repeated reallocations during JSON serialization. - // Typical KMIP responses are 300-800 bytes; 512 avoids reallocs for most responses. let mut buf = Vec::with_capacity(512); serde_json::to_writer(&mut buf, &ttlv)?; Ok(HttpResponse::Ok() @@ -260,8 +262,9 @@ pub(crate) async fn kmip_json( /// Handle KMIP requests with JSON content type async fn kmip_json_inner(req_http: HttpRequest, body: Bytes, kms: Data>) -> KResult { - // Recover the user from the request + // Recover the user and OPA context from the request let user = kms.get_user(&req_http); + let opa_ctx = kms.extract_opa_context(&req_http); // Deserialize the body directly to TTLV (avoiding intermediate Vec + Value allocations) let body_str = @@ -280,8 +283,11 @@ async fn kmip_json_inner(req_http: HttpRequest, body: Bytes, kms: Data> if (major == 2 && minor == 1) || (major == 1 && minor == 4) { let span = tracing::info_span!("kmip", user = user.as_str(), tag = ttlv.tag.as_str()); - handle_ttlv(&kms, ttlv, &user, major, minor) - .instrument(span) + OPA_USER_CONTEXT + .scope( + opa_ctx, + Box::pin(handle_ttlv(&kms, ttlv, &user, major, minor)).instrument(span), + ) .await } else { Err(KmsError::InvalidRequest( @@ -296,11 +302,14 @@ pub(crate) async fn kmip_binary( body: Bytes, kms: Data>, ) -> HttpResponse { - // Recover the user from the request + // Recover the user and OPA context from the request let user = kms.get_user(&req_http); + let opa_ctx = kms.extract_opa_context(&req_http); - // Handle the TTLV bytes request - let response_bytes = handle_ttlv_bytes(&user, body.as_ref(), &kms).await; + // Handle the TTLV bytes request, scoped to the per-request OPA context + let response_bytes = OPA_USER_CONTEXT + .scope(opa_ctx, handle_ttlv_bytes(&user, body.as_ref(), &kms)) + .await; // Send the response HttpResponse::Ok() diff --git a/crate/server/src/routes/ui_auth.rs b/crate/server/src/routes/ui_auth.rs index 60a9f21a3b..6efc683687 100644 --- a/crate/server/src/routes/ui_auth.rs +++ b/crate/server/src/routes/ui_auth.rs @@ -337,6 +337,10 @@ pub(crate) struct AuthVerifierLoginRequest { password: String, #[serde(default)] totp_code: Option, + /// Realm to authenticate against. Required when multiple realms are configured; + /// when omitted the first configured realm is used. + #[serde(default)] + realm: Option, } /// Mirrors the Auth Verifier server's `AuthenticationResult` shape @@ -369,9 +373,9 @@ struct AuthVerifierLoginResponse { /// mirroring `auth_verifier_login()` in `kms/crate/clients/client/src/http_client/login.rs` — /// then validates the JWT the AS returns via `Set-Cookie: _ea_=` using the same /// JWKS-backed trust logic as the bearer-token `AuthVerifier` middleware -/// (`verify_auth_verifier_jwt_subject`). Only the resulting `sub` (username) is stored in the -/// session; the JWT itself never reaches the browser, keeping the same BFF guarantee -/// as the OIDC flow (`callback`, above). +/// (`verify_auth_verifier_jwt`). The resulting `sub` (username), `roles`, and `domain` +/// are stored in the server-side session so subsequent browser requests carry the full +/// OPA context via `SessionAuth`; the JWT itself never reaches the browser. #[post("/login_as")] pub(crate) async fn login_as( session: Session, @@ -390,15 +394,31 @@ pub(crate) async fn login_as( ); }; // Guaranteed non-empty by `ui_login_enabled()`. - let (Some(server_url), Some(realm)) = ( - config.auth_verifier_url.as_deref(), - config.auth_verifier_realm.as_deref(), - ) else { + let Some(server_url) = config.auth_verifier_url.as_deref() else { return HttpResponse::InternalServerError().json( serde_json::json!({ "error": "The Auth Verifier server is not configured for the Web UI" }), ); }; + // Resolve the realm: use the one from the request body (if provided and allowed), + // otherwise fall back to the first configured realm. + let configured_realms = config.realms(); + let realm = match body.realm.as_deref() { + Some(r) if configured_realms.contains(&r.to_owned()) => r, + Some(r) => { + return HttpResponse::BadRequest().json( + serde_json::json!({ "error": format!("Realm '{r}' is not configured on this server") }), + ); + } + None => match config.primary_realm() { + Some(r) => r, + None => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({ "error": "No realm configured for the Web UI" })); + } + }, + }; + let Ok(mut url) = Url::parse(server_url.trim_end_matches('/')) else { return HttpResponse::InternalServerError() .json(serde_json::json!({ "error": "Invalid Auth Verifier server URL" })); @@ -494,19 +514,30 @@ pub(crate) async fn login_as( ); }; - let user_id = match crate::middlewares::verify_auth_verifier_jwt_subject(jwks_manager, &token).await + let claims = + match crate::middlewares::verify_auth_verifier_jwt(jwks_manager, &token).await { + Ok(c) => c, + Err(e) => { + return HttpResponse::Unauthorized().json( + serde_json::json!({ "error": format!("Failed to validate the Auth Verifier server token: {e}") }), + ); + } + }; + + // Store username, roles, and domain in the server-side session so that + // subsequent browser requests (via SessionAuth) carry the full OPA context. + if session.insert("user_id", &claims.sub).is_err() + || session.insert("roles", &claims.roles).is_err() { - Ok(sub) => sub, - Err(e) => { - return HttpResponse::Unauthorized().json( - serde_json::json!({ "error": format!("Failed to validate the Auth Verifier server token: {e}") }), + return HttpResponse::InternalServerError() + .json(serde_json::json!({ "error": "Failed to store session data" })); + } + if let Some(ref domain) = claims.domain { + if session.insert("domain", domain).is_err() { + return HttpResponse::InternalServerError().json( + serde_json::json!({ "error": "Failed to store domain in session" }), ); } - }; - - if session.insert("user_id", &user_id).is_err() { - return HttpResponse::InternalServerError() - .json(serde_json::json!({ "error": "Failed to store user_id in session" })); } HttpResponse::Ok().json(AuthVerifierLoginResponse { @@ -569,7 +600,10 @@ pub(crate) async fn logout( } #[get("/auth_method")] -pub(crate) async fn get_auth_method(auth_methods: web::Data>) -> HttpResponse { +pub(crate) async fn get_auth_method( + auth_methods: web::Data>, + auth_verifier_runtime: web::Data, +) -> HttpResponse { let methods = auth_methods.get_ref(); // The singular `auth_method` is kept for backward compatibility: it is the // highest-priority configured method (`auth_methods[0]`), or `"None"` when no @@ -580,9 +614,15 @@ pub(crate) async fn get_auth_method(auth_methods: web::Data>) -> Htt .cloned() .unwrap_or_else(|| "None".to_owned()); + // When multiple realms are configured the UI shows a realm selector before + // the username/password form. Always include the list so the UI can avoid a + // second round-trip. + let realms: &[String] = auth_verifier_runtime.config.realms(); + HttpResponse::Ok().json(serde_json::json!({ "auth_method": primary, "auth_methods": methods, + "auth_verifier_realms": realms, })) } @@ -601,6 +641,11 @@ mod tests { use actix_web::{App, test, web}; use super::get_auth_method; + use crate::config::AuthVerifierRuntimeConfig; + + fn no_auth_verifier() -> web::Data { + web::Data::new(AuthVerifierRuntimeConfig::default()) + } #[actix_web::test] async fn test_auth_method_returns_cosmian_when_configured() { @@ -608,6 +653,7 @@ mod tests { let app = test::init_service( App::new() .app_data(web::Data::new(auth_methods)) + .app_data(no_auth_verifier()) .service(get_auth_method), ) .await; @@ -627,6 +673,11 @@ mod tests { .map(|a| a.iter().filter_map(|v| v.as_str()).collect::>()), Some(vec!["AUTH_VERIFIER"]) ); + // No realms when auth_verifier not fully configured. + assert_eq!( + body.get("auth_verifier_realms").and_then(|v| v.as_array()), + Some(&vec![]) + ); } #[actix_web::test] @@ -635,6 +686,7 @@ mod tests { let app = test::init_service( App::new() .app_data(web::Data::new(auth_methods)) + .app_data(no_auth_verifier()) .service(get_auth_method), ) .await; @@ -666,6 +718,7 @@ mod tests { let app = test::init_service( App::new() .app_data(web::Data::new(auth_methods)) + .app_data(no_auth_verifier()) .service(get_auth_method), ) .await; @@ -686,4 +739,38 @@ mod tests { Some(vec!["JWT", "AUTH_VERIFIER", "CERT"]) ); } + + #[actix_web::test] + async fn test_auth_method_returns_realms_for_multi_realm_config() { + use crate::config::AuthVerifierConfig; + + let auth_methods: Vec = vec!["AUTH_VERIFIER".to_owned()]; + let av_config = AuthVerifierRuntimeConfig { + config: AuthVerifierConfig { + auth_verifier_url: Some("https://auth.example.com".to_owned()), + auth_verifier_realm: Some(vec!["acme.com".to_owned(), "partner.com".to_owned()]), + ..Default::default() + }, + ..Default::default() + }; + let app = test::init_service( + App::new() + .app_data(web::Data::new(auth_methods)) + .app_data(web::Data::new(av_config)) + .service(get_auth_method), + ) + .await; + + let req = test::TestRequest::get().uri("/auth_method").to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body: serde_json::Value = test::read_body_json(resp).await; + let realms: Vec<&str> = body + .get("auth_verifier_realms") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + assert_eq!(realms, vec!["acme.com", "partner.com"]); + } } diff --git a/crate/server/src/tests/crl_tests.rs b/crate/server/src/tests/crl_tests.rs index 47fbc4d6f4..dcdece8bed 100644 --- a/crate/server/src/tests/crl_tests.rs +++ b/crate/server/src/tests/crl_tests.rs @@ -39,13 +39,13 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ Certify, Get, GetAttributes, GetAttributesResponse, Revoke, RevokeResponse, }, kmip_types::{ - CertificateAttributes, CryptographicAlgorithm, Link, LinkType, LinkedObjectIdentifier, - UniqueIdentifier, VendorAttribute, VendorAttributeValue, + CertificateAttributes, CertificateRequestType, CryptographicAlgorithm, Link, LinkType, + LinkedObjectIdentifier, UniqueIdentifier, VendorAttribute, VendorAttributeValue, }, }, }; -use openssl::x509::X509Crl; -use x509_parser::prelude::{CertificateRevocationList, FromDer}; +use openssl::x509::{X509Crl, X509NameBuilder, X509ReqBuilder}; +use x509_parser::prelude::{CertificateRevocationList, FromDer, X509Certificate}; use crate::{ config::ServerParams, @@ -56,16 +56,6 @@ use crate::{ tests::test_utils::{https_clap_config, https_clap_config_opts, setup_app}, }; -trait TestUserIdExt { - fn new(value: impl Into) -> Self; -} - -impl TestUserIdExt for UserId { - fn new(value: impl Into) -> Self { - Self::try_new(value).expect("test user ID should be valid") - } -} - // ── Extension strings ──────────────────────────────────────────────────────── /// CA certificate extension: has `cRLSign` (required by our new enforcement). @@ -1452,3 +1442,188 @@ async fn test_crl_counting_revoked_certs_with_co() -> KResult<()> { } Ok(()) } + +// ── CSR-based Certify with TTL ──────────────────────────────────────────────── +// +// Regression tests for the SPIRE / kmip-go limitation documented at +// https://github.com/spiffe/spire/pull/7235#discussion_r3829548963 +// +// The Eviden KMS already honours the `requested_validity_days` vendor attribute +// on CSR-based Certify requests. These tests lock that behaviour in place so +// that future refactors cannot accidentally regress it. + +/// Build a self-signed PKCS#10 CSR (PEM-encoded) for testing. +/// +/// Uses RSA-2048 so the test runs in both FIPS and non-FIPS modes. +/// Returns the PEM bytes of the CSR (not the private key — the KMS signs +/// the certificate with the issuer's key, not the subject's key). +fn generate_test_csr(cn: &str) -> Vec { + use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa}; + + let rsa = Rsa::generate(2048).expect("RSA key"); + let pkey = PKey::from_rsa(rsa).expect("PKey"); + + let mut name = X509NameBuilder::new().expect("X509NameBuilder"); + name.append_entry_by_text("C", "FR").expect("C"); + name.append_entry_by_text("O", "KMS Test").expect("O"); + name.append_entry_by_text("CN", cn).expect("CN"); + let name = name.build(); + + let mut builder = X509ReqBuilder::new().expect("X509ReqBuilder"); + builder.set_pubkey(&pkey).expect("set pubkey"); + builder.set_subject_name(&name).expect("set subject name"); + builder + .sign(&pkey, MessageDigest::sha256()) + .expect("sign CSR"); + + builder.build().to_pem().expect("CSR to PEM") +} + +/// CSR-based `Certify` with `requested_validity_days` vendor attribute must +/// produce a certificate whose `not_after` matches the requested TTL, **not** +/// the server default (365 days). +/// +/// This is the positive regression test: the vendor attribute already works; +/// this test makes it impossible to regress silently. +#[tokio::test] +async fn test_certify_from_csr_with_requested_validity_days() -> KResult<()> { + const TTL_DAYS: i32 = 30; + let kms = make_kms().await?; + let owner = UserId::new("csr_ttl_owner"); + + // 1. Create a root CA with cRLSign so CRL generation also works. + let (ca_id, ca_sk_id) = certify(&kms, &owner, "CSR-TTL Root CA", None, None, CA_EXT).await?; + + // 2. Generate a CSR signed by a fresh local RSA key. + let csr_pem = generate_test_csr("CSR-TTL Subject"); + + // 3. Certify the CSR with a 30-day TTL via the `requested_validity_days` + // vendor attribute — the path SPIRE/kmip-go uses. + let attrs = Attributes { + link: Some(vec![ + Link { + link_type: LinkType::PrivateKeyLink, + linked_object_identifier: LinkedObjectIdentifier::TextString(ca_sk_id), + }, + Link { + link_type: LinkType::CertificateLink, + linked_object_identifier: LinkedObjectIdentifier::TextString(ca_id), + }, + ]), + vendor_attributes: Some(vec![VendorAttribute { + vendor_identification: VENDOR_ID_COSMIAN.to_owned(), + attribute_name: "requested_validity_days".to_owned(), + attribute_value: VendorAttributeValue::Integer(TTL_DAYS), + }]), + ..Attributes::default() + }; + let cert_id = kms + .certify( + Certify { + certificate_request_type: Some(CertificateRequestType::PEM), + certificate_request_value: Some(csr_pem), + attributes: Some(attrs), + ..Certify::default() + }, + &owner, + ) + .await? + .unique_identifier + .to_string(); + + // 4. Retrieve and parse the issued certificate. + let cert_der = get_cert_der(&kms, &owner, &cert_id).await; + let (_, cert) = X509Certificate::from_der(&cert_der).expect("issued cert must parse as X.509"); + + // 5. Assert `not_after ≈ now + TTL_DAYS` (±1 day tolerance for CI timing). + let not_after = cert.validity().not_after.timestamp(); + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_secs(); + let now = i64::try_from(now_secs).unwrap_or(i64::MAX); + let actual_days = (not_after - now) / 86_400; + let ttl_i64 = i64::from(TTL_DAYS); + + assert!( + (ttl_i64 - 1..=ttl_i64 + 1).contains(&actual_days), + "CSR-based Certify with requested_validity_days={TTL_DAYS}: \ + expected not_after ≈ {TTL_DAYS} days from now, got {actual_days} days" + ); + + // 6. The subject CN must come from the CSR, not from request attributes. + let cn = cert + .subject() + .iter_common_name() + .next() + .and_then(|cn| cn.as_str().ok()) + .unwrap_or(""); + assert_eq!( + cn, "CSR-TTL Subject", + "subject CN must be taken from the CSR, not from request attributes" + ); + + Ok(()) +} + +/// CSR-based `Certify` with **no** `requested_validity_days` attribute falls +/// back to the server default of 365 days. +/// +/// This test is the counterpart of `test_certify_from_csr_with_requested_validity_days`: +/// it ensures the default path is not inadvertently affected when the optional +/// TTL attribute is absent. +#[tokio::test] +async fn test_certify_from_csr_default_validity() -> KResult<()> { + let kms = make_kms().await?; + let owner = UserId::new("csr_default_owner"); + + let (ca_id, ca_sk_id) = + certify(&kms, &owner, "CSR-Default Root CA", None, None, CA_EXT).await?; + + let csr_pem = generate_test_csr("CSR-Default Subject"); + + let attrs = Attributes { + link: Some(vec![ + Link { + link_type: LinkType::PrivateKeyLink, + linked_object_identifier: LinkedObjectIdentifier::TextString(ca_sk_id), + }, + Link { + link_type: LinkType::CertificateLink, + linked_object_identifier: LinkedObjectIdentifier::TextString(ca_id), + }, + ]), + ..Attributes::default() + }; + let cert_id = kms + .certify( + Certify { + certificate_request_type: Some(CertificateRequestType::PEM), + certificate_request_value: Some(csr_pem), + attributes: Some(attrs), + ..Certify::default() + }, + &owner, + ) + .await? + .unique_identifier + .to_string(); + + let cert_der = get_cert_der(&kms, &owner, &cert_id).await; + let (_, cert) = X509Certificate::from_der(&cert_der).expect("issued cert must parse as X.509"); + + let not_after = cert.validity().not_after.timestamp(); + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_secs(); + let now = i64::try_from(now_secs).unwrap_or(i64::MAX); + let actual_days = (not_after - now) / 86_400; + + assert!( + (364..=366).contains(&actual_days), + "CSR-based Certify with no TTL attribute must default to ~365 days, got {actual_days} days" + ); + + Ok(()) +} diff --git a/crate/server/src/tests/key_ceremony_tests.rs b/crate/server/src/tests/key_ceremony_tests.rs index bf00f4410b..8922886b28 100644 --- a/crate/server/src/tests/key_ceremony_tests.rs +++ b/crate/server/src/tests/key_ceremony_tests.rs @@ -35,7 +35,6 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_2_1::{ use crate::{ config::{ClapConfig, MainDBConfig, ServerParams}, core::{KMS, operations::perform_crypto_officer_ceremony_activation}, - error::KmsError, middlewares::UserId, result::KResult, tests::test_utils::get_tmp_sqlite_path, @@ -45,24 +44,8 @@ use crate::{ const TEST_CEREMONY_SECRET: &str = "deadbeefcafebabe0102030405060708090a0b0c0d0e0f10deadbeefcafebabe"; -/// Extract the text-string from a `UniqueIdentifier`, propagating as a `KResult`. -/// Replaces `.as_str().expect("UID must be a string")` throughout this file so -/// a malformed server response produces a descriptive error rather than a panic. -fn uid_string(uid: &UniqueIdentifier) -> KResult { - uid.as_str() - .map(ToOwned::to_owned) - .ok_or_else(|| KmsError::InvalidRequest(format!("expected TextString UID, got {uid:?}"))) -} - -/// Build a base `ClapConfig` for CO-related tests. -/// -/// - `require_ceremony`: when `true`, also sets `ceremony_secret = TEST_CEREMONY_SECRET` -/// - `wrap_key_id`: when `Some`, sets `ceremony_wrapping_key_id` -fn base_ceremony_conf( - co_users: Vec, - require_ceremony: bool, - wrap_key_id: Option<&str>, -) -> ClapConfig { +/// Build a `KMS` configured for ceremony mode with the given CO users. +async fn ceremony_kms(co_users: Vec) -> KResult> { let mut conf = ClapConfig { db: MainDBConfig { database_type: Some("sqlite".to_owned()), @@ -73,19 +56,9 @@ fn base_ceremony_conf( ..Default::default() }; conf.roles.crypto_officer_users = Some(co_users); - conf.roles.crypto_officer_require_ceremony = require_ceremony; - if require_ceremony { - conf.roles.ceremony_secret = Some(TEST_CEREMONY_SECRET.to_owned()); - } - if let Some(id) = wrap_key_id { - conf.roles.ceremony_wrapping_key_id = Some(id.to_owned()); - } - conf -} + conf.roles.crypto_officer_require_ceremony = true; + conf.roles.ceremony_secret = Some(TEST_CEREMONY_SECRET.to_owned()); -/// Build a `KMS` configured for ceremony mode with the given CO users. -async fn ceremony_kms(co_users: Vec) -> KResult> { - let conf = base_ceremony_conf(co_users, true, None); let params = ServerParams::try_from(conf)?; Ok(Arc::new(KMS::instantiate(Arc::new(params)).await?)) } @@ -95,7 +68,22 @@ async fn ceremony_kms(co_users: Vec) -> KResult> { /// A fresh wrapping key is pre-created and its UID is set as `ceremony_wrapping_key_id`. /// The returned `Arc` is ready for split-key operations that wrap shares at rest. async fn ceremony_kms_with_wrapping(co_users: Vec, wrap_key_id: &str) -> KResult> { - let conf = base_ceremony_conf(co_users.clone(), true, Some(wrap_key_id)); + // First, start a temporary ceremony KMS without wrapping to create the key. + let mut conf = ClapConfig { + db: MainDBConfig { + database_type: Some("sqlite".to_owned()), + sqlite_path: get_tmp_sqlite_path(), + clear_database: false, + ..Default::default() + }, + ..Default::default() + }; + conf.roles.crypto_officer_users = Some(co_users.clone()); + conf.roles.crypto_officer_require_ceremony = true; + conf.roles.ceremony_secret = Some(TEST_CEREMONY_SECRET.to_owned()); + // Wrapping key ID is configured from the start so that `CreateSplitKey` uses it. + conf.roles.ceremony_wrapping_key_id = Some(wrap_key_id.to_owned()); + let params = ServerParams::try_from(conf)?; let kms = Arc::new(KMS::instantiate(Arc::new(params)).await?); @@ -116,9 +104,19 @@ async fn ceremony_kms_with_wrapping(co_users: Vec, wrap_key_id: &str) -> Ok(kms) } - async fn config_only_co_kms(co_users: Vec) -> KResult> { - let conf = base_ceremony_conf(co_users, false, None); + let mut conf = ClapConfig { + db: MainDBConfig { + database_type: Some("sqlite".to_owned()), + sqlite_path: get_tmp_sqlite_path(), + clear_database: false, + ..Default::default() + }, + ..Default::default() + }; + conf.roles.crypto_officer_users = Some(co_users); + conf.roles.crypto_officer_require_ceremony = false; + let params = ServerParams::try_from(conf)?; Ok(Arc::new(KMS::instantiate(Arc::new(params)).await?)) } @@ -137,7 +135,11 @@ async fn create_key(kms: &KMS, owner: &str) -> KResult { )?; req.attributes.activation_date = None; let resp = kms.create(req, &UserId::from(owner)).await?; - uid_string(&resp.unique_identifier) + Ok(resp + .unique_identifier + .as_str() + .expect("UID must be a string") + .to_owned()) } /// Split `key_uid` into `total_parts` XOR shares; return share UIDs. @@ -157,10 +159,11 @@ async fn split_key( protection_storage_masks: None, }; let resp = Box::pin(kms.create_split_key(req, &UserId::from(owner))).await?; - resp.unique_identifier + Ok(resp + .unique_identifier .iter() - .map(uid_string) - .collect::>>() + .map(|u| u.as_str().expect("UID must be a string").to_owned()) + .collect()) } /// Reconstruct from the given share UIDs; return the reconstructed key UID. @@ -181,7 +184,11 @@ async fn join_shares( protection_storage_masks: None, }; let resp = kms.join_split_key(req, &UserId::from(user)).await?; - uid_string(&resp.unique_identifier) + Ok(resp + .unique_identifier + .as_str() + .expect("UID must be a string") + .to_owned()) } // ─── Test 1: config-only CO ────────────────────────────────────────────────── @@ -1040,152 +1047,7 @@ async fn test_post_revocation_co_is_demoted_to_operator() -> KResult<()> { Ok(()) } -// ─── Test 19: reconstructed-key storage — per activation path ───────────────── - -/// **`POST /access/crypto_officer/ceremony/activate` (UI path)**: -/// The reconstructed key is computed in RAM to derive its hash, then zeroized. -/// It is **never stored** in the `objects` table. After activation the base key -/// UID (i.e., the share root without the `#N` suffix) must be absent from the DB. -/// -/// **`JoinSplitKey` KMIP operation (CLI path)**: -/// The reconstructed key IS stored as an Active managed object before the ceremony -/// activation side-effect runs. After `join_shares` the base UID must exist. -#[cfg(feature = "non-fips")] -#[tokio::test] -async fn test_activation_endpoint_does_not_store_reconstructed_key() -> KResult<()> { - let alice = "alice@example.com"; - let bob = "bob@example.com"; - let carol = "carol@example.com"; - let provisioner = "admin"; - let n = 3_i32; - - let kms = ceremony_kms(vec![alice.to_owned(), bob.to_owned(), carol.to_owned()]).await?; - - // ── Create the ceremony key with a deterministic UID ────────────────────── - let key_uid = "ceremony-key-rest-path-test"; - let no_tags: &[&str] = &[]; - let mut req = symmetric_key_create_request( - VENDOR_ID_COSMIAN, - Some(UniqueIdentifier::TextString(key_uid.to_owned())), - 256, - CryptographicAlgorithm::AES, - no_tags, - false, - None, - )?; - req.attributes.activation_date = None; - kms.create(req, &UserId::from(provisioner)).await?; - - let shares = Box::pin(split_key(&kms, provisioner, key_uid, n)).await?; - - // ── Source key is destroyed after the split ─────────────────────────────── - assert!( - kms.database.retrieve_object(key_uid).await?.is_none(), - "Source key must be destroyed after CreateSplitKey" - ); - - // ── Grant alice access to bob and carol's shares ────────────────────────── - kms.database - .grant_operations( - &shares[1], - &UserId::from(alice), - std::collections::HashSet::from([KmipOperation::Get]), - ) - .await?; - kms.database - .grant_operations( - &shares[2], - &UserId::from(alice), - std::collections::HashSet::from([KmipOperation::Get]), - ) - .await?; - - // ── Activate via the REST path (perform_crypto_officer_ceremony_activation) ─ - // This is what the UI calls — it must NOT store the reconstructed key. - perform_crypto_officer_ceremony_activation(&kms, &shares, &UserId::from(alice)).await?; - - assert!( - kms.is_crypto_officer(&UserId::from(alice)).await?, - "Alice must be an active CO after the REST activation" - ); - - // The reconstructed key UID (base UID without #N suffix) must NOT be in DB. - let reconstructed_uid = key_uid; // base UID = source key UID = ceremony-key-rest-path-test - assert!( - kms.database - .retrieve_object(reconstructed_uid) - .await? - .is_none(), - "REST activation path must NOT store the reconstructed key in the DB" - ); - - Ok(()) -} - -/// **`JoinSplitKey` (CLI path)** stores the reconstructed key as a managed object. -/// -/// This is the complementary test: the KMIP path explicitly persists the key so -/// the caller can use it as a wrapping/encryption key after ceremony completion. -#[cfg(feature = "non-fips")] -#[tokio::test] -async fn test_join_split_key_stores_reconstructed_key() -> KResult<()> { - let alice = "alice@example.com"; - let bob = "bob@example.com"; - let carol = "carol@example.com"; - let provisioner = "admin"; - let n = 3_i32; - - let kms = ceremony_kms(vec![alice.to_owned(), bob.to_owned(), carol.to_owned()]).await?; - - let key_uid = "ceremony-key-kmip-path-test"; - let no_tags: &[&str] = &[]; - let mut req = symmetric_key_create_request( - VENDOR_ID_COSMIAN, - Some(UniqueIdentifier::TextString(key_uid.to_owned())), - 256, - CryptographicAlgorithm::AES, - no_tags, - false, - None, - )?; - req.attributes.activation_date = None; - kms.create(req, &UserId::from(provisioner)).await?; - - let shares = Box::pin(split_key(&kms, provisioner, key_uid, n)).await?; - - kms.database - .grant_operations( - &shares[1], - &UserId::from(alice), - std::collections::HashSet::from([KmipOperation::Get]), - ) - .await?; - kms.database - .grant_operations( - &shares[2], - &UserId::from(alice), - std::collections::HashSet::from([KmipOperation::Get]), - ) - .await?; - - // ── Activate via JoinSplitKey (KMIP / CLI path) ─────────────────────────── - // This DOES store the reconstructed key before ceremony activation runs. - let reconstructed_uid = join_shares(&kms, alice, &shares, ObjectType::SymmetricKey).await?; - - assert!( - kms.is_crypto_officer(&UserId::from(alice)).await?, - "Alice must be an active CO after JoinSplitKey" - ); - - // The reconstructed key must exist in the DB. - let owm = kms.database.retrieve_object(&reconstructed_uid).await?; - assert!( - owm.is_some(), - "KMIP JoinSplitKey path must store the reconstructed key in the DB (uid={reconstructed_uid})" - ); - - Ok(()) -} +// ─── Test 17: 3-CO case — simultaneous activation (per-user design) ───────── /// Per-user model: each CO candidate activates independently. Multiple CO users /// can be simultaneously active. When Carol activates after Alice, Alice remains @@ -1553,7 +1415,11 @@ async fn test_co_cannot_get_sensitive_key_without_wrapping() -> KResult<()> { req.attributes.activation_date = None; req.attributes.sensitive = Some(true); // mark sensitive let create_resp = kms.create(req, &UserId::from(alice)).await?; - let key_uid = uid_string(&create_resp.unique_identifier)?; + let key_uid = create_resp + .unique_identifier + .as_str() + .expect("UID must be a string") + .to_owned(); // Alice (active CO and owner) tries to Get her own key without a wrapping specification. let get_req = Get { @@ -2038,8 +1904,8 @@ async fn test_generic_split_without_wrapping_key_roundtrip() -> KResult<()> { let share_uids: Vec = resp .unique_identifier .iter() - .map(uid_string) - .collect::>>()?; + .map(|u| u.as_str().expect("UID must be a string").to_owned()) + .collect(); assert_eq!(share_uids.len(), 2, "expected 2 unwrapped shares"); // Reconstruct — source key is still alive (no ceremony destruction). @@ -2122,6 +1988,173 @@ async fn test_join_wrapped_shares_fails_after_wrapping_key_deleted() -> KResult< Ok(()) } +// ─── Regression — OPA enforcing mode does not deadlock CO ceremony ──────────── + +/// **BUG REGRESSION**: CO ceremony was impossible in OPA enforcing mode. +/// +/// ## Root cause +/// +/// In `user_has_permission()`, the OPA Gate 1 bypass for "native COs" used +/// `kms.is_crypto_officer(user)`, which returns **false** for ceremony candidates +/// who have not yet activated. This created a chicken-and-egg deadlock: +/// +/// - Ceremony candidates need to `Get` peer shares (held by other COs) in order to +/// call `JoinSplitKey` and complete the ceremony. +/// - Before ceremony completion, `is_crypto_officer()` → false → OPA Gate 1 runs. +/// - OPA sees `roles = []` (mTLS auth, no JWT) and `is_owner = false` (peer share) +/// → **deny** → ceremony blocked. +/// +/// ## Fix +/// +/// Extend `is_native_co` in `user_has_permission` to also include users listed in +/// `crypto_officer.users`, regardless of ceremony completion. These users bypass +/// OPA Gate 1 (which is designed for JWT-role enforcement) but still go through the +/// legacy gate (DB ownership + grant checks). +/// +/// ## Feedback loop +/// +/// We call `user_has_permission` directly with: +/// - OPA enforcing + unreachable URL (any OPA call → fail-closed deny) +/// - alice is a CO candidate (in `co_users`) but ceremony not yet complete +/// - alice has an explicit DB `Get` grant on bob's key +/// +/// BEFORE fix: `is_native_co = is_crypto_officer(alice) = false` +/// → OPA Gate 1 runs → unreachable → `false` → alice cannot access bob's key. +/// +/// AFTER fix: `is_native_co = alice in co_users → true` +/// → OPA Gate 1 bypassed → legacy gate: alice has DB grant → `true`. +#[cfg(feature = "non-fips")] +#[tokio::test] +async fn test_ceremony_not_blocked_by_opa_enforcing_mode() -> KResult<()> { + use std::collections::HashSet; + + use crate::core::retrieve_object_utils::user_has_permission; + + let alice = "alice@example.com"; + let bob = "bob@example.com"; + let carol = "carol@example.com"; + + // ── 1. Build a ceremony KMS with OPA enforcing + unreachable URL ───────── + // + // Any operation that reaches OPA Gate 1 will fail-close (deny), because + // the reqwest client gets ECONNREFUSED on 127.0.0.1:1 and `unwrap_or(false)` + // translates the error into a deny. + let mut conf = ClapConfig { + db: MainDBConfig { + database_type: Some("sqlite".to_owned()), + sqlite_path: get_tmp_sqlite_path(), + clear_database: false, + ..Default::default() + }, + ..Default::default() + }; + conf.roles.crypto_officer_users = + Some(vec![alice.to_owned(), bob.to_owned(), carol.to_owned()]); + conf.roles.crypto_officer_require_ceremony = true; + conf.roles.ceremony_secret = Some(TEST_CEREMONY_SECRET.to_owned()); + // OPA enforcing mode, port 1 is always refused — OPA Gate 1 calls always deny. + conf.opa.opa_url = Some("http://127.0.0.1:1".to_owned()); + conf.opa.opa_mode = "enforcing".to_owned(); + + let params = ServerParams::try_from(conf)?; + let kms = Arc::new(KMS::instantiate(Arc::new(params)).await?); + + // ── 2. Set up state via server API ──────────────────────────────────────── + // + // `kms.create()` calls `enforce_create_permission()` which checks + // `is_privileged = user in co_users` — alice and bob are in co_users, so the + // OPA Create check is bypassed. The keys are stored in the DB with the + // respective users as owners. + let alice_key_uid = create_key(&kms, alice).await?; + let bob_key_uid = create_key(&kms, bob).await?; + + // Grant alice explicit GET access on bob's key (direct DB — no OPA path). + kms.database + .grant_operations( + &bob_key_uid, + &UserId::from(alice), + HashSet::from([KmipOperation::Get]), + ) + .await?; + + // ── 3. Retrieve ObjectWithMetadata directly (no OPA) ───────────────────── + let alice_objects = kms + .database + .retrieve_objects(crate::core::ObjectHandle::from(alice_key_uid.as_str())) + .await?; + let alice_owm = alice_objects + .into_values() + .next() + .expect("alice's key must be in DB"); + + let bob_objects = kms + .database + .retrieve_objects(crate::core::ObjectHandle::from(bob_key_uid.as_str())) + .await?; + let bob_owm = bob_objects + .into_values() + .next() + .expect("bob's key must be in DB"); + + // ── 4. Verify alice can access her OWN key ──────────────────────────────── + // + // Before fix: fails (alice not is_native_co, OPA unreachable → deny even for owner). + // After fix: passes (alice in co_users → bypass OPA Gate 1 → legacy gate: owner → allow). + let alice_can_get_own = user_has_permission( + &UserId::from(alice), + Some(&alice_owm), + &KmipOperation::Get, + &kms, + ) + .await?; + assert!( + alice_can_get_own, + "CO candidate alice must be able to GET her own key in OPA enforcing mode \ + (CO candidates bypass OPA Gate 1 — is_owner path in legacy gate must apply)" + ); + + // ── 5. Verify alice can access BOB's key via explicit grant ────────────── + // + // This is the direct ceremony deadlock scenario: alice needs to Get a peer's + // share before she can call JoinSplitKey. + // + // Before fix: fails (alice not is_native_co, OPA Gate 1: is_owner=false, + // roles=[] → unreachable → deny). + // After fix: passes (alice in co_users → bypass OPA Gate 1 → legacy gate: + // alice has explicit Get grant → allow). + let alice_can_get_bobs = user_has_permission( + &UserId::from(alice), + Some(&bob_owm), + &KmipOperation::Get, + &kms, + ) + .await?; + assert!( + alice_can_get_bobs, + "CO candidate alice must be able to GET bob's key via explicit DB grant \ + when OPA is enforcing with unreachable URL (CO candidates bypass Gate 1)" + ); + + // ── 6. Verify bob CANNOT access alice's key without a grant ───────────── + // + // After fix, CO candidates still go through the legacy gate for peer objects. + // Bob has no grant on alice's key — the legacy gate must deny. + let bob_can_get_alice = user_has_permission( + &UserId::from(bob), + Some(&alice_owm), + &KmipOperation::Get, + &kms, + ) + .await?; + assert!( + !bob_can_get_alice, + "CO candidate bob must NOT be able to GET alice's key without an explicit grant \ + (legacy gate must still apply for CO candidates)" + ); + + Ok(()) +} + /// Regression test for GitHub issue #909: a non-CO user granted only `Get` on the /// wildcard object identifier `*` tries to bypass the Create/Import authorization gate. #[cfg(feature = "non-fips")] diff --git a/crate/server/src/tests/test_modify_attribute.rs b/crate/server/src/tests/test_modify_attribute.rs index 55f79ce01c..56cc31b368 100644 --- a/crate/server/src/tests/test_modify_attribute.rs +++ b/crate/server/src/tests/test_modify_attribute.rs @@ -66,6 +66,7 @@ async fn create_key_with_state(kms: &Arc, state: State) -> KResult &object, object.attributes()?, &HashSet::new(), + "", ) .await?; // Also persist the requested state in the dedicated state column. diff --git a/crate/server/src/tests/test_set_attribute.rs b/crate/server/src/tests/test_set_attribute.rs index 14f68293d6..c2a2c21b50 100644 --- a/crate/server/src/tests/test_set_attribute.rs +++ b/crate/server/src/tests/test_set_attribute.rs @@ -112,6 +112,7 @@ pub(crate) async fn test_set_attribute_server() -> KResult<()> { &sym_key_object, sym_key_object.attributes()?, &HashSet::new(), + "", ) .await?; diff --git a/crate/server_database/src/ceremony_keys.rs b/crate/server_database/src/ceremony_keys.rs index e3e8b81b36..4a16dde775 100644 --- a/crate/server_database/src/ceremony_keys.rs +++ b/crate/server_database/src/ceremony_keys.rs @@ -18,6 +18,7 @@ use cosmian_kms_crypto::reexport::cosmian_crypto_core::{ reexport::rand_core::SeedableRng, }; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::error::{DbError, DbResult}; @@ -40,11 +41,9 @@ pub struct CeremonyPayload { /// /// # Zeroization /// -/// Both sensitive fields are heap-pinned via `Pin>` inside -/// `SymmetricKey<32>` → `Secret<32>` and are auto-zeroized on drop: -/// - `aes_key`: `ZeroizeOnDrop` via `#[derive(ZeroizeOnDrop)]` on `SymmetricKey` -/// - `obfuscation_key`: same — heap-pinned, so moves never copy the 32 key bytes; -/// only the fat pointer is moved. +/// Both sensitive fields are automatically wiped on drop: +/// - `aes_key` wraps `SymmetricKey<32>` → `Secret<32>: ZeroizeOnDrop` +/// - `obfuscation_key` is wrapped in `Zeroizing<[u8; 32]>: ZeroizeOnDrop` /// /// The `Aes256Gcm` cipher is **not** stored as a field because /// `aes_gcm::AesGcm` does not implement `ZeroizeOnDrop` (the round-key schedule @@ -52,11 +51,10 @@ pub struct CeremonyPayload { /// locally inside each `seal`/`unseal` call from `aes_key` and dropped /// immediately after use. pub struct CeremonyKeys { - /// Raw AES-256 key bytes — heap-pinned, auto-zeroized on drop via `SymmetricKey: ZeroizeOnDrop`. + /// Raw AES-256 key bytes — auto-zeroized on drop via `Secret<32>: ZeroizeOnDrop`. aes_key: SymmetricKey<32>, /// Key material for SHAKE-256 obfuscation of Redis key names. - /// Heap-pinned: moves copy only the fat pointer, not the 32 key bytes. - obfuscation_key: SymmetricKey<32>, + obfuscation_key: Zeroizing<[u8; 32]>, /// Thread-safe RNG for nonce generation. rng: Mutex, } @@ -72,7 +70,7 @@ impl CeremonyKeys { let mut aes_key = SymmetricKey::<32>::default(); kdf256!(&mut *aes_key, ceremony_secret, b"ceremony_aes_key"); - let mut obfuscation_key = SymmetricKey::<32>::default(); + let mut obfuscation_key = Zeroizing::new([0_u8; 32]); kdf256!( &mut *obfuscation_key, ceremony_secret, diff --git a/crate/server_database/src/core/database_objects.rs b/crate/server_database/src/core/database_objects.rs index 1b724507e9..79fdf3c19c 100644 --- a/crate/server_database/src/core/database_objects.rs +++ b/crate/server_database/src/core/database_objects.rs @@ -206,6 +206,7 @@ impl Database { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> DbResult { if let Some(ref uid) = uid { reject_reserved_uid(uid)?; @@ -214,7 +215,11 @@ impl Database { let db = self .get_object_store(uid.as_deref().unwrap_or_default()) .await?; - Ok(db.create(uid, owner, object, attributes, tags).await?) + let uid = db + .create(uid, owner, object, attributes, tags, domain) + .await?; + // New objects never have a cache entry; nothing to invalidate. + Ok(uid) }) .await } @@ -856,6 +861,7 @@ mod tests { &key, &attributes, &HashSet::new(), + "", ) .await; let err = result.expect_err("creating an object with uid '*' must fail"); @@ -1109,7 +1115,7 @@ mod tests { ..Default::default() }; let uid = db - .create(None, &owner, &cert_object, &attributes, &HashSet::new()) + .create(None, &owner, &cert_object, &attributes, &HashSet::new(), "") .await .expect("failed to create certificate object"); @@ -1142,7 +1148,7 @@ mod tests { object_type: Some(ObjectType::Certificate), ..Default::default() }; - db.create(None, &owner, &cert_object, &attributes, &HashSet::new()) + db.create(None, &owner, &cert_object, &attributes, &HashSet::new(), "") .await .expect("failed to create certificate object"); diff --git a/crate/server_database/src/core/object_cache.rs b/crate/server_database/src/core/object_cache.rs index 91b7010e80..a9c9afedaa 100644 --- a/crate/server_database/src/core/object_cache.rs +++ b/crate/server_database/src/core/object_cache.rs @@ -166,6 +166,7 @@ mod tests { "test-owner".to_owned(), State::Active, Attributes::default(), + String::new(), ) } diff --git a/crate/server_database/src/core/unwrapped_cache.rs b/crate/server_database/src/core/unwrapped_cache.rs index 6d2cd25fc8..71aa4d846e 100644 --- a/crate/server_database/src/core/unwrapped_cache.rs +++ b/crate/server_database/src/core/unwrapped_cache.rs @@ -265,6 +265,7 @@ mod tests { &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await?; assert_eq!(&uid, &uid_); diff --git a/crate/server_database/src/stores/redis/additional_redis_findex_tests.rs b/crate/server_database/src/stores/redis/additional_redis_findex_tests.rs index eb4a72f53f..97f13077b7 100644 --- a/crate/server_database/src/stores/redis/additional_redis_findex_tests.rs +++ b/crate/server_database/src/stores/redis/additional_redis_findex_tests.rs @@ -432,6 +432,7 @@ pub(crate) async fn test_live_count_counter() -> DbResult<()> { &key1, key1.attributes()?, &HashSet::new(), + "", ) .await?; let uid2 = db @@ -441,6 +442,7 @@ pub(crate) async fn test_live_count_counter() -> DbResult<()> { &key2, key2.attributes()?, &HashSet::new(), + "", ) .await?; let uid3 = db @@ -450,6 +452,7 @@ pub(crate) async fn test_live_count_counter() -> DbResult<()> { &key3, key3.attributes()?, &HashSet::new(), + "", ) .await?; @@ -552,6 +555,7 @@ pub(crate) async fn test_active_key_count_counter() -> DbResult<()> { &key1, key1.attributes()?, &HashSet::new(), + "", ) .await?; let uid_key2 = db @@ -561,6 +565,7 @@ pub(crate) async fn test_active_key_count_counter() -> DbResult<()> { &key2, key2.attributes()?, &HashSet::new(), + "", ) .await?; @@ -579,6 +584,7 @@ pub(crate) async fn test_active_key_count_counter() -> DbResult<()> { &opaque, &Attributes::default(), &HashSet::new(), + "", ) .await?; let raw: Option = db.mgr.clone().get(ACTIVE_KEY_COUNT_KEY).await?; diff --git a/crate/server_database/src/stores/redis/redis_with_findex.rs b/crate/server_database/src/stores/redis/redis_with_findex.rs index 3ac0fba06d..cc061de160 100644 --- a/crate/server_database/src/stores/redis/redis_with_findex.rs +++ b/crate/server_database/src/stores/redis/redis_with_findex.rs @@ -528,6 +528,7 @@ impl ObjectsStore for RedisWithFindex { object: &Object, attributes: &Attributes, tags: &HashSet, + _domain: &str, ) -> InterfaceResult { let (uid, db_object) = self .prepare_object_for_create(uid, owner.as_str(), object, attributes, tags) @@ -558,6 +559,7 @@ impl ObjectsStore for RedisWithFindex { o.owner, o.state, o.attributes.unwrap_or_default(), + String::new(), ) }) })?) diff --git a/crate/server_database/src/stores/sql/mysql.rs b/crate/server_database/src/stores/sql/mysql.rs index 155eb04ebc..f3e191fdb9 100644 --- a/crate/server_database/src/stores/sql/mysql.rs +++ b/crate/server_database/src/stores/sql/mysql.rs @@ -79,6 +79,7 @@ fn my_sql_row_to_owm(row: &mysql_async::Row) -> Result Result, + domain: &str, ) -> InterfaceResult { async fn transact( tx: &mut Transaction<'_>, @@ -452,8 +454,9 @@ impl ObjectsStore for MySqlPool { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> DbResult { - create_(uid, owner, object, attributes, tags, tx).await + create_(uid, owner, object, attributes, tags, domain, tx).await } let max_retries = MYSQL_DEADLOCK_MAX_RETRIES; for attempt in 0..max_retries { @@ -462,7 +465,17 @@ impl ObjectsStore for MySqlPool { .start_transaction(mysql_async::TxOpts::default()) .await .map_err(DbError::from)?; - match transact(&mut tx, uid.clone(), owner, object, attributes, tags).await { + match transact( + &mut tx, + uid.clone(), + owner, + object, + attributes, + tags, + domain, + ) + .await + { Ok(v) => match tx.commit().await { Ok(()) => return Ok(v), Err(e) => { @@ -1118,6 +1131,7 @@ pub(super) async fn create_( object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, tx: &mut Transaction<'_>, ) -> DbResult { let object_json = serde_json::to_string_pretty(object).map_err(|e| { @@ -1137,6 +1151,7 @@ pub(super) async fn create_( attributes.state.unwrap_or(State::PreActive).to_string(), owner.to_owned(), wrapping_key_id, + domain.to_owned(), ), ) .await @@ -1569,7 +1584,7 @@ pub(super) async fn atomic_( match operation { AtomicOperation::Create((uid, _owner_field, object, attributes, tags)) => { if let Err(e) = - create_(Some(uid.clone()), owner, object, attributes, tags, tx).await + create_(Some(uid.clone()), owner, object, attributes, tags, "", tx).await { db_bail!("creation of object {uid} failed: {e}"); } diff --git a/crate/server_database/src/stores/sql/pgsql.rs b/crate/server_database/src/stores/sql/pgsql.rs index 86af65cc77..d51444e30a 100644 --- a/crate/server_database/src/stores/sql/pgsql.rs +++ b/crate/server_database/src/stores/sql/pgsql.rs @@ -519,6 +519,7 @@ impl ObjectsStore for PgPool { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> InterfaceResult { async fn transact( tx: &deadpool_postgres::Transaction<'_>, @@ -527,6 +528,7 @@ impl ObjectsStore for PgPool { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> DbResult { let object_json = serde_json::to_string(object).map_err(DbError::from)?; let attributes_json = serde_json::to_value(attributes).map_err(DbError::from)?; @@ -546,6 +548,7 @@ impl ObjectsStore for PgPool { &state, &owner, &wrapping_key_id, + &domain, ], ) .await @@ -566,7 +569,7 @@ impl ObjectsStore for PgPool { let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); pg_retry_tx!(self.pool, |tx| { - transact(&tx, &uid, owner, object, attributes, tags).await + transact(&tx, &uid, owner, object, attributes, tags, domain).await }) } @@ -591,10 +594,11 @@ impl ObjectsStore for PgPool { .map_err(|e| InterfaceError::from(DbError::from(e)))?; let owner: String = row.get(3); let state_str: String = row.get(4); + let domain: String = row.try_get(5).unwrap_or_default(); let state = State::try_from(state_str.as_str()) .map_err(|e| InterfaceError::from(DbError::from(e)))?; Ok(Some(ObjectWithMetadata::new( - id, object, owner, state, attributes, + id, object, owner, state, attributes, domain, ))) } else { Ok(None) @@ -730,6 +734,7 @@ impl ObjectsStore for PgPool { .map_err(DbError::from)?; let attrs_param = Json(&attributes_json); let owner_s: &str = owner; + let domain = ""; tx.execute( &stmt, &[ @@ -739,6 +744,7 @@ impl ObjectsStore for PgPool { &state, &owner_s, &wrapping_key_id, + &domain, ], ) .await diff --git a/crate/server_database/src/stores/sql/query.sql b/crate/server_database/src/stores/sql/query.sql index 332eb2634c..02dfe17ccb 100644 --- a/crate/server_database/src/stores/sql/query.sql +++ b/crate/server_database/src/stores/sql/query.sql @@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS objects ( attributes jsonb NOT NULL, state VARCHAR(32), owner VARCHAR(255), - wrapping_key_id VARCHAR(128) + wrapping_key_id VARCHAR(128), + domain VARCHAR(255) NOT NULL DEFAULT '' ); -- name: add-column-attributes ALTER TABLE objects ADD COLUMN attributes json; @@ -34,6 +35,10 @@ SELECT attributes from objects; -- name: add-column-wrapping-key-id ALTER TABLE objects ADD COLUMN IF NOT EXISTS wrapping_key_id VARCHAR(128); +-- name: add-column-domain +ALTER TABLE objects ADD COLUMN domain VARCHAR(255) NOT NULL DEFAULT ''; +-- name: has-column-domain +SELECT domain from objects; -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( @@ -60,10 +65,10 @@ DELETE FROM read_access; DELETE FROM tags; -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) VALUES ($1, $2, $3, $4, $5, $6); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id, domain) VALUES ($1, $2, $3, $4, $5, $6, $7); -- name: select-object -SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state +SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state, objects.domain FROM objects WHERE objects.id=$1; diff --git a/crate/server_database/src/stores/sql/query_mysql.sql b/crate/server_database/src/stores/sql/query_mysql.sql index 1a26d8889e..b0caa6e772 100644 --- a/crate/server_database/src/stores/sql/query_mysql.sql +++ b/crate/server_database/src/stores/sql/query_mysql.sql @@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS objects attributes json NOT NULL, state VARCHAR(32), owner VARCHAR(255), - wrapping_key_id VARCHAR(128) + wrapping_key_id VARCHAR(128), + domain VARCHAR(255) NOT NULL DEFAULT '' ); -- name: add-column-attributes @@ -51,6 +52,13 @@ SHOW COLUMNS FROM crypto_officer_activations LIKE 'activated_by'; -- name: add-column-co-activated-by ALTER TABLE crypto_officer_activations ADD COLUMN activated_by VARCHAR(255); +-- name: add-column-domain +ALTER TABLE objects + ADD COLUMN domain VARCHAR(255) NOT NULL DEFAULT ''; + +-- name: has-column-domain +SHOW COLUMNS FROM objects LIKE 'domain'; + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( @@ -82,11 +90,11 @@ FROM tags; -- name: insert-objects -INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id) -VALUES (?, ?, ?, ?, ?, ?); +INSERT INTO objects (id, object, attributes, state, owner, wrapping_key_id, domain) +VALUES (?, ?, ?, ?, ?, ?, ?); -- name: select-object -SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state +SELECT objects.id, objects.object, objects.attributes, objects.owner, objects.state, objects.domain FROM objects WHERE objects.id = ?; diff --git a/crate/server_database/src/stores/sql/sqlite.rs b/crate/server_database/src/stores/sql/sqlite.rs index bff2759697..668f09f8fe 100644 --- a/crate/server_database/src/stores/sql/sqlite.rs +++ b/crate/server_database/src/stores/sql/sqlite.rs @@ -141,6 +141,7 @@ impl SqlitePool { let clean_objects = pool.get_query("clean-table-objects")?.to_owned(); let clean_read_access = pool.get_query("clean-table-read_access")?.to_owned(); let clean_tags = pool.get_query("clean-table-tags")?.to_owned(); + let add_column_domain = pool.get_query("add-column-domain")?.to_owned(); pool.writer .call( move |c: &mut rusqlite::Connection| -> Result<(), rusqlite::Error> { @@ -157,6 +158,11 @@ impl SqlitePool { [], )?; tx.execute(&replace_dollars_with_qn(&create_crls), [])?; + // Migration: add domain column if missing (existing databases) + let has_domain: bool = tx.prepare("SELECT domain FROM objects LIMIT 0").is_ok(); + if !has_domain { + tx.execute(&add_column_domain, [])?; + } if clear_database { tx.execute(&clean_objects, [])?; tx.execute(&clean_read_access, [])?; @@ -395,13 +401,14 @@ fn sqlite_row_to_owm(row: &Row<'_>) -> Result { let attributes_json: String = row.get(2)?; let owner: String = row.get(3)?; let state_str: String = row.get(4)?; + let domain: String = row.get::<_, String>(5).unwrap_or_default(); let object: Object = serde_json::from_str(&object_json)?; let object = migrate_block_cipher_mode_if_needed(object); let attributes: Attributes = serde_json::from_str(&attributes_json)?; let state = State::try_from(state_str.as_str()).map_err(|e| DbError::DatabaseError(e.to_string()))?; Ok(ObjectWithMetadata::new( - id, object, owner, state, attributes, + id, object, owner, state, attributes, domain, )) } @@ -414,6 +421,7 @@ impl ObjectsStore for SqlitePool { object: &Object, attributes: &Attributes, tags: &HashSet, + domain: &str, ) -> InterfaceResult { let uid = uid.unwrap_or_else(|| Uuid::new_v4().to_string()); // If an explicit UID already exists, return a clear error matching CLI expectations @@ -441,6 +449,7 @@ impl ObjectsStore for SqlitePool { let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s: String = owner.as_str().to_owned(); let wrapping_key_id = object.wrapping_key_uid(); + let domain_s = domain.to_owned(); let insert_object = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let insert_tag = replace_dollars_with_qn(get_sqlite_query!("insert-tags")); @@ -460,6 +469,7 @@ impl ObjectsStore for SqlitePool { state_s, owner_s, wrapping_key_id, + &domain_s, ], )?; for tag in &tags_owned { @@ -1434,6 +1444,7 @@ fn create_sqlite( let sql = replace_dollars_with_qn(get_sqlite_query!("insert-objects")); let state_s = attributes.state.unwrap_or(State::PreActive).to_string(); let owner_s: String = owner.to_owned(); + let domain_s = String::new(); tx.execute( &sql, rusqlite::params![ @@ -1442,7 +1453,8 @@ fn create_sqlite( attributes_json, state_s, owner_s, - wrapping_key_id + wrapping_key_id, + domain_s ], )?; diff --git a/crate/server_database/src/tests/database_tests.rs b/crate/server_database/src/tests/database_tests.rs index e97048d29c..9b683bf250 100644 --- a/crate/server_database/src/tests/database_tests.rs +++ b/crate/server_database/src/tests/database_tests.rs @@ -243,6 +243,7 @@ pub(super) async fn atomic(db: &DB) -> DbResult<()> { &symmetric_key_3, symmetric_key_3.attributes()?, &HashSet::new(), + "", ) .await?; @@ -307,6 +308,7 @@ pub(super) async fn upsert(db: &DB) -> DbResult<()> { &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await?; @@ -383,6 +385,7 @@ pub(super) async fn crud(db: &DB) -> DbResult<()> { &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await?; assert_eq!(&uid, &uid_); @@ -476,6 +479,7 @@ pub(super) async fn block_cipher_mode_migration_after_json_deserialization(db: &DB) -> DbR &make_key(&mut rng)?, &attrs_due, &HashSet::new(), + "", ) .await?; @@ -578,6 +583,7 @@ pub(super) async fn find_due_for_rotation_test(db: &DB) -> DbR &make_key(&mut rng)?, &attrs_not_due, &HashSet::new(), + "", ) .await?; @@ -596,6 +602,7 @@ pub(super) async fn find_due_for_rotation_test(db: &DB) -> DbR &make_key(&mut rng)?, &attrs_no_auto, &HashSet::new(), + "", ) .await?; @@ -672,6 +679,7 @@ pub(super) async fn wrapping_key_link_test(db: &DB) -> DbResul &wrapped_obj, &attributes, &HashSet::new(), + "", ) .await?; @@ -695,6 +703,7 @@ pub(super) async fn wrapping_key_link_test(db: &DB) -> DbResul &plain_obj, &attributes, &HashSet::new(), + "", ) .await?; diff --git a/crate/server_database/src/tests/find_attributes_test.rs b/crate/server_database/src/tests/find_attributes_test.rs index ce92a558c0..f81fd227cb 100644 --- a/crate/server_database/src/tests/find_attributes_test.rs +++ b/crate/server_database/src/tests/find_attributes_test.rs @@ -61,6 +61,7 @@ pub(super) async fn find_attributes(db: &DB) -> DbResult<()> { &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await?; assert_eq!(&uid, &uid_); diff --git a/crate/server_database/src/tests/json_access_test.rs b/crate/server_database/src/tests/json_access_test.rs index e25aa7bd6b..48bfdee33e 100644 --- a/crate/server_database/src/tests/json_access_test.rs +++ b/crate/server_database/src/tests/json_access_test.rs @@ -47,6 +47,7 @@ pub(super) async fn json_access(db: &DB) -> &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await .context("create")?; diff --git a/crate/server_database/src/tests/list_uids_for_tags_test.rs b/crate/server_database/src/tests/list_uids_for_tags_test.rs index 623255dbab..fb5f025329 100644 --- a/crate/server_database/src/tests/list_uids_for_tags_test.rs +++ b/crate/server_database/src/tests/list_uids_for_tags_test.rs @@ -44,6 +44,7 @@ pub(super) async fn list_uids_for_tags_test &symmetric_key, symmetric_key.attributes()?, &HashSet::from([tag1.clone()]), + "", ) .await?; @@ -67,6 +68,7 @@ pub(super) async fn list_uids_for_tags_test &symmetric_key, symmetric_key.attributes()?, &HashSet::from([tag1.clone(), tag2.clone()]), + "", ) .await?; diff --git a/crate/server_database/src/tests/owner_test.rs b/crate/server_database/src/tests/owner_test.rs index ae09ccfe50..e9ae67997f 100644 --- a/crate/server_database/src/tests/owner_test.rs +++ b/crate/server_database/src/tests/owner_test.rs @@ -41,6 +41,7 @@ pub(super) async fn owner(db: &DB) -> DbRes &symmetric_key, symmetric_key.attributes()?, &HashSet::new(), + "", ) .await?; diff --git a/crate/server_database/src/tests/tagging_tests.rs b/crate/server_database/src/tests/tagging_tests.rs index 2531c77251..a72df16789 100644 --- a/crate/server_database/src/tests/tagging_tests.rs +++ b/crate/server_database/src/tests/tagging_tests.rs @@ -51,6 +51,7 @@ pub(super) async fn tags( &symmetric_key, symmetric_key.attributes()?, &HashSet::from(["tag1".to_owned(), "tag2".to_owned()]), + "", ) .await?; assert_eq!(&uid, &uid_); diff --git a/crate/test_kms_server/Cargo.toml b/crate/test_kms_server/Cargo.toml index 4564607890..d68f21f51c 100644 --- a/crate/test_kms_server/Cargo.toml +++ b/crate/test_kms_server/Cargo.toml @@ -49,5 +49,5 @@ toml = { workspace = true } [dev-dependencies] criterion = { workspace = true } futures = { workspace = true } -reqwest = { workspace = true, features = ["rustls-tls", "cookies"] } +reqwest = { workspace = true, features = ["rustls-tls", "json", "cookies", "cookies"] } zeroize = { workspace = true } diff --git a/crate/test_kms_server/benches/http_throughput.rs b/crate/test_kms_server/benches/http_throughput.rs index f7336f15f9..d25fd65413 100644 --- a/crate/test_kms_server/benches/http_throughput.rs +++ b/crate/test_kms_server/benches/http_throughput.rs @@ -182,7 +182,7 @@ fn bench_http_throughput(c: &mut Criterion) { .build() .expect("failed to build tokio runtime for bench"); - let config_path = test_config_path("auth_plain.toml"); + let config_path = test_config_path("auth/plain.toml"); let mut group = c.benchmark_group("kms_bench"); group.throughput(Throughput::Elements(CONCURRENCY as u64)); diff --git a/crate/test_kms_server/src/test_server.rs b/crate/test_kms_server/src/test_server.rs index bf595bb1f9..b2c64b19a9 100644 --- a/crate/test_kms_server/src/test_server.rs +++ b/crate/test_kms_server/src/test_server.rs @@ -144,7 +144,7 @@ fn root_dir() -> PathBuf { /// Returns the absolute path to a test server TOML configuration file. /// -/// `name` should be just the filename (e.g. `"auth_plain.toml"`). +/// `name` should be a path relative to `test_data/configs/server` (e.g. `"auth/plain.toml"`). /// This resolves correctly regardless of which crate is calling it. #[must_use] pub fn test_config_path(name: &str) -> PathBuf { diff --git a/crate/test_kms_server/src/vector_runner.rs b/crate/test_kms_server/src/vector_runner.rs index 4edcc22d6d..5e8dbc3e98 100644 --- a/crate/test_kms_server/src/vector_runner.rs +++ b/crate/test_kms_server/src/vector_runner.rs @@ -26,11 +26,11 @@ static ONCE_VECTOR_POSTGRESQL: OnceCell = OnceCell::const_new(); static ONCE_VECTOR_MYSQL: OnceCell = OnceCell::const_new(); /// Singleton server for vector tests on the `Redis-findex` backend. static ONCE_VECTOR_REDIS_FINDEX: OnceCell = OnceCell::const_new(); -/// Singleton server for vector tests requiring mTLS cert-auth (`cert_auth.toml`). +/// Singleton server for vector tests requiring mTLS cert-auth (`auth/cert.toml`). static ONCE_VECTOR_CERT_AUTH: OnceCell = OnceCell::const_new(); -/// Singleton server for vector tests requiring server-only TLS (`auth_https.toml`). +/// Singleton server for vector tests requiring server-only TLS (`auth/tls.toml`). static ONCE_VECTOR_AUTH_HTTPS: OnceCell = OnceCell::const_new(); -/// Singleton server for Operator/CryptoOfficer test vectors (`cert_auth_operator_and_crypto_officer.toml`). +/// Singleton server for Operator/CryptoOfficer test vectors (`auth/cert_roles.toml`). static ONCE_VECTOR_CERT_AUTH_OPERATOR_CRYPTO_OFFICER: OnceCell = OnceCell::const_new(); /// Singleton server for vector tests requiring `SoftHSM2` + KEK. @@ -158,18 +158,39 @@ pub struct TestManifest { pub steps: Vec, } -/// TLS client-certificate identity for a specific user in a test vector. +/// Client identity for a specific user in a test vector. /// -/// Paths are relative to the repository root. -/// On macOS (native-tls / Security.framework), PEM identity loading is not -/// supported. The runner auto-detects a `.p12` file next to the `.crt` and -/// uses it with password `"password"` (standard test infrastructure convention). +/// Supports two mutually exclusive authentication modes: +/// - **mTLS** (`client_cert` + `client_key`): paths relative to the repository root. +/// - **JWT** (`access_token_env`): the named env var holds a Bearer JWT. +/// +/// When `access_token_env` is set the mTLS fields are ignored. #[derive(Debug, Deserialize, Clone)] pub struct IdentityConfig { - /// Path to the PEM client certificate + /// Path to the PEM client certificate (mTLS identity). + /// Leave empty when `access_token_env` is set. + #[serde(default)] pub client_cert: String, - /// Path to the PEM client private key + /// Path to the PEM client private key (mTLS identity). + /// Leave empty when `access_token_env` is set. + #[serde(default)] pub client_key: String, + /// Name of an environment variable that holds a Bearer JWT for this identity. + /// + /// When set, the runner reads the JWT from the named env var and uses it + /// as the `access_token` for all requests from this identity. `client_cert` + /// and `client_key` are ignored when this field is present. + /// + /// The env var must be populated (e.g. by the test-setup function) before + /// the vector executes. + /// + /// Example: + /// ```toml + /// [identities.user_role] + /// access_token_env = "KMS_TEST_OPA_USER_ROLE_JWT" + /// ``` + #[serde(default)] + pub access_token_env: Option, } /// Captures the Nth occurrence of a repeated TTLV tag from a response. @@ -1289,8 +1310,12 @@ async fn execute_generate_crl_step( /// Build one `KmsClient` per named identity declared in `manifest.identities`. /// -/// Always uses PEM (`.crt` + `.key`) so the runner works in both FIPS and -/// non-FIPS builds (PKCS12KDF is not available in FIPS mode). +/// Two identity modes are supported: +/// - **mTLS**: `client_cert` + `client_key` fields point to PEM files. +/// - **JWT**: `access_token_env` names an env var that holds the Bearer token. +/// +/// Always uses PEM (not PKCS#12) for mTLS so the runner works in both FIPS and +/// non-FIPS builds (`PKCS12KDF` is not available in FIPS mode). fn build_identity_clients( context: &TestsContext, manifest: &TestManifest, @@ -1298,13 +1323,38 @@ fn build_identity_clients( ) -> Result, KmsClientError> { let mut identity_clients: HashMap = HashMap::new(); for (name, id_cfg) in &manifest.identities { - let cert_path = root.join(&id_cfg.client_cert); - let key_path = root.join(&id_cfg.client_key); let mut http_cfg = context.owner_client_config.http_config.clone(); - http_cfg.tls_client_pem_cert_path = Some(cert_path.to_string_lossy().into_owned()); - http_cfg.tls_client_pem_key_path = Some(key_path.to_string_lossy().into_owned()); + + // Always clear PKCS#12 — only PEM-based mTLS is supported in the vector runner. http_cfg.tls_client_pkcs12_path = None; http_cfg.tls_client_pkcs12_password = None; + + if let Some(env_name) = &id_cfg.access_token_env { + // JWT-based identity: read the Bearer token from the named env var. + let jwt = std::env::var(env_name).map_err(|_e| { + KmsClientError::UnexpectedError(format!( + "identity '{name}': env var '{env_name}' (access_token_env) is not set" + )) + })?; + // Strip an optional "Bearer " prefix: the HTTP client adds it when building + // the Authorization header, so storing a pre-prefixed value would produce + // "Authorization: Bearer Bearer " and fail authentication. + let jwt = jwt + .strip_prefix("Bearer ") + .map(str::to_owned) + .unwrap_or(jwt); + http_cfg.access_token = Some(jwt); + // Clear any mTLS settings inherited from the context config. + http_cfg.tls_client_pem_cert_path = None; + http_cfg.tls_client_pem_key_path = None; + } else { + // mTLS identity: use certificate + key paths from the manifest. + let cert_path = root.join(&id_cfg.client_cert); + let key_path = root.join(&id_cfg.client_key); + http_cfg.tls_client_pem_cert_path = Some(cert_path.to_string_lossy().into_owned()); + http_cfg.tls_client_pem_key_path = Some(key_path.to_string_lossy().into_owned()); + } + let cfg = KmsClientConfig { http_config: http_cfg, vendor_id: VENDOR_ID_COSMIAN.to_owned(), @@ -5131,4 +5181,1000 @@ ObjectType = "SymmetricKey" ); Ok(()) } + + // ── OPA authorization policy vectors ──────────────────────────────────────── + // Mode 1 (disabled): no OPA — baseline that proves the infrastructure does not + // break normal owner operations. + // Mode 2 (exclusive): OPA is the sole authority; KMS legacy check is skipped. + // Mode 3 (enforcing): both OPA and KMS legacy must allow. + // + // "allowed" variants require KMS_OPA_URL + KMS_AUTH_SERVER_URL (real services). + // "denied" variants require KMS_OPA_URL only (mTLS two-cert scenario). + // All four external-service tests skip gracefully when the env vars are absent. + // + // auth_verifier variants exercise the `AuthVerifier` bearer-token middleware path + // (handle_auth_verifier → roles + domain extracted from JWT) as opposed to the + // OIDC jwt_auth_provider path tested by the standard "allowed" variants. + + /// Singleton OPA-enabled KMS servers (one per mode × `test_type`). + static ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED: OnceCell = OnceCell::const_new(); + /// Shared cert-auth OPA server for both exclusive and enforcing "denied" variants. + /// Both modes exercise the same scenario (non-owner, no roles → deny), so a single + /// server avoids concurrent macOS Keychain PKCS#12 loading conflicts. + static ONCE_VECTOR_OPA_DENIED: OnceCell = OnceCell::const_new(); + static ONCE_VECTOR_OPA_ENFORCING_ALLOWED: OnceCell = OnceCell::const_new(); + /// Auth Verifier path: exclusive mode — exercises `handle_auth_verifier` (not `handle_jwt`). + static ONCE_VECTOR_OPA_AUTH_VERIFIER_EXCLUSIVE: OnceCell = OnceCell::const_new(); + /// Auth Verifier path: enforcing mode. + static ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING: OnceCell = OnceCell::const_new(); + /// Auth Verifier path: exclusive mode, `SuperAdmin` JWT as the owner. + /// Separate cell so the `SuperAdmin` test owns its own server and its JWT + /// is always used for initialization regardless of test execution order. + static ONCE_VECTOR_OPA_AUTH_VERIFIER_SUPER_ADMIN: OnceCell = + OnceCell::const_new(); + /// OPA exclusive mode + cert auth + NO `crypto_officer_users`: proves native KMS + /// COs without JWT cannot create in exclusive mode (OPA is sole authority). + static ONCE_VECTOR_OPA_EXCLUSIVE_NATIVE_CO_DENIED: OnceCell = + OnceCell::const_new(); + /// OPA enforcing mode + cert auth + `crypto_officer_users` set: proves native KMS + /// COs (privileged, no JWT) can create because the KMS privilege bypass applies + /// in enforcing mode (not exclusive mode). + static ONCE_VECTOR_OPA_ENFORCING_NATIVE_CO_ALLOWED: OnceCell = + OnceCell::const_new(); + + /// Start (or reuse) an OPA-enabled KMS server for "allowed" vectors. + /// + /// Reads the `CryptoOfficer` JWT from `KMS_TEST_OPA_OFFICER_JWT`, which must be + /// set by the `mise test:opa_rbac` bash script before invoking this test. + /// The script starts the auth server, provisions test users via + /// `provision_opa_users.sh`, and exports all required JWT env vars. + /// + /// Returns `None` when `KMS_OPA_URL`, `KMS_AUTH_SERVER_URL`, or + /// `KMS_TEST_OPA_OFFICER_JWT` is not set (graceful skip instead of failure). + /// + /// Required env vars (set by the bash script): + /// `KMS_OPA_URL` — OPA REST API base URL + /// `KMS_AUTH_SERVER_URL` — auth server JWKS base URL + /// `KMS_TEST_OPA_OFFICER_JWT` — `CryptoOfficer` JWT (kms-opa-test) + /// `KMS_TEST_OPA_USER_ROLE_JWT` — User role JWT (kms-opa-test) + /// `KMS_TEST_OPA_AUDITOR_JWT` — Auditor JWT (kms-opa-test) + /// `KMS_TEST_OPA_DOMAIN_ADMIN_OTHER_JWT` — `DomainAdmin` JWT (kms-opa-other) + /// `KMS_TEST_OPA_OTHER_DOMAIN_JWT` — `CryptoOfficer` JWT (kms-opa-other) + /// Start (or reuse) an OPA-enabled KMS server for "allowed" vectors. + /// + /// Patches `auth/plain.toml` with OPA URL, mode, and a `IdP` pointing to the + /// auth server's JWKS endpoint. The owner client sends the `CryptoOfficer` JWT + /// as a Bearer token; since KMS test mode uses `insecure_decode`, no real + /// JWKS fetch occurs and the JWT is accepted as-is. + /// + /// Returns `None` when `KMS_OPA_URL`, `KMS_AUTH_SERVER_URL`, or + /// `KMS_TEST_OPA_OFFICER_JWT` is not set. + async fn get_or_init_opa_allowed_server( + cell: &'static OnceCell, + opa_mode: &'static str, + ) -> Result, KmsClientError> { + let Ok(opa_url) = std::env::var("KMS_OPA_URL") else { + return Ok(None); + }; + let Ok(auth_server_url) = std::env::var("KMS_AUTH_SERVER_URL") else { + return Ok(None); + }; + + // Read the pre-provisioned CryptoOfficer JWT set by the bash script + // (test_opa_rbac.sh Phase 3 / provision_opa_users.sh). + let Ok(officer_jwt) = std::env::var("KMS_TEST_OPA_OFFICER_JWT") else { + eprintln!( + "SKIP: KMS_TEST_OPA_OFFICER_JWT not set — \ + run `mise test:opa_rbac` to provision users and export JWT env vars" + ); + return Ok(None); + }; + + let config_path = crate::test_config_path("auth/plain.toml"); + let ctx = cell + .get_or_try_init(|| { + let opa_url_c = opa_url.clone(); + let auth_url_c = auth_server_url.clone(); + let jwt_c = officer_jwt.clone(); + async move { + crate::start_test_server_with_patch( + &config_path, + move |cfg| { + cfg.opa.opa_url = Some(opa_url_c); + cfg.opa.opa_mode = opa_mode.to_owned(); + cfg.idp_auth.jwt_auth_provider = Some(vec![format!( + "cosmian-auth-test,{auth_url_c}/public/jwks" + )]); + // Disable Google CSE: auth/plain.toml enables it, but server + // startup tries to create the CSE RSA key as the default user + // who has no OPA roles → denied in enforcing/exclusive mode. + cfg.google_cse_config.google_cse_enable = false; + }, + crate::TestClientOptions { + http: cosmian_kms_client::reexport::cosmian_http_client::HttpClientConfig { + access_token: Some(jwt_c), + ..Default::default() + }, + send_jwt: false, + send_client_cert: false, + send_api_token: true, + }, + ) + .await + } + }) + .await?; + + Ok(Some(ctx)) + } + + /// Start (or reuse) an OPA-enabled KMS server for "denied" vectors. + /// + /// Patches `auth/cert.toml` with OPA URL + mode. The two TLS identities + /// (owner cert vs user cert) map to distinct KMS usernames. The user cert + /// has no JWT → no roles → OPA denies (not owner, no `SuperAdmin`/`CryptoOfficer`). + /// + /// Returns `None` when `KMS_OPA_URL` is not set. + async fn get_or_init_opa_denied_server( + cell: &'static OnceCell, + opa_mode: &'static str, + ) -> Result, KmsClientError> { + let Ok(opa_url) = std::env::var("KMS_OPA_URL") else { + return Ok(None); + }; + + let config_path = crate::test_config_path("auth/cert.toml"); + let ctx = cell + .get_or_try_init(|| { + let opa_url_c = opa_url.clone(); + async move { + crate::start_test_server_with_patch( + &config_path, + move |cfg| { + cfg.opa.opa_url = Some(opa_url_c); + cfg.opa.opa_mode = opa_mode.to_owned(); + // Use a dedicated port so the OPA denied server does not + // conflict with ONCE_VECTOR_CERT_AUTH (auth/cert.toml port 9999). + cfg.http.port = 13001; + // Disable Google CSE: auth/cert.toml enables it, but the OPA + // server startup would try to create the CSE RSA key as the + // default user who has no OPA roles → denied in enforcing mode. + cfg.google_cse_config.google_cse_enable = false; + // The test vector owner uses mTLS cert with CN "owner.client@acme.com". + // Cert-auth users carry no JWT roles, so OPA would deny their Create. + // Adding the owner as a privileged_user lets the KMS bypass OPA for + // the Create step while OPA still denies the non-owner cert user for + // all subsequent operations (Get, Destroy). + cfg.privileged_users = Some(vec!["owner.client@acme.com".to_owned()]); + // Disable the socket server to avoid conflicting on the + // fixed socket port across concurrent test processes. + cfg.socket_server.socket_server_start = false; + cfg.db.sqlite_path = + PathBuf::from(format!("/tmp/kms_test_opa_{opa_mode}_denied")); + cfg.workspace.root_data_path = + PathBuf::from(format!("/tmp/kms_test_opa_{opa_mode}_denied_ws")); + }, + crate::TestClientOptions::default(), + ) + .await + } + }) + .await?; + + Ok(Some(ctx)) + } + + #[tokio::test] + async fn test_vec_opa_mode_disabled() -> Result<(), KmsClientError> { + crate::init_test_logging(); + run_test_vector("test_data/vectors/opa/mode_disabled").await + } + + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_allowed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_allowed", ctx).await + } + + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_denied_server(&ONCE_VECTOR_OPA_DENIED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_denied", ctx).await + } + + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_allowed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_ENFORCING_ALLOWED, "enforcing").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_enforcing_allowed", ctx).await + } + + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + // Reuse the shared denied server (same deny reason: non-owner, no roles). + // Both exclusive and enforcing deny via OPA; the mode check only differs + // in whether legacy KMS access control is also checked (both deny here). + let Some(ctx) = get_or_init_opa_denied_server(&ONCE_VECTOR_OPA_DENIED, "enforcing").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_enforcing_denied", ctx).await + } + + /// OPA negative: `User` role cannot `Get` (export key material) on a non-owned key. + /// + /// The `user_ops` set in `kms.rego` deliberately excludes `Get` (which exposes raw key + /// bytes). Even though the user has a valid JWT and a recognised role, OPA returns + /// `allow = false` because `Get ∉ user_ops`. + /// + /// Requires `KMS_OPA_URL` + `KMS_AUTH_SERVER_URL`; JWT env vars are provisioned by + /// `mise test:opa_rbac` / `provision_opa_users.sh`. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_user_role_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_user_role_denied", ctx) + .await + } + + /// OPA negative: cross-domain isolation — `CryptoOfficer` in domain `kms-opa-other` + /// must NOT access objects created in domain `kms-opa-test`. + /// + /// The `same_domain` helper in `kms.rego` requires `input.user_domain == + /// input.object_domain`. A `CryptoOfficer` with `as_domain = "kms-opa-other"` trying + /// to `Get` a key owned by `kms-opa-test` fails this check → `allow = false`. + /// + /// Requires `KMS_OPA_URL` + `KMS_AUTH_SERVER_URL`; JWT env vars are provisioned by + /// `mise test:opa_rbac` / `provision_opa_users.sh`. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_wrong_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_wrong_domain", ctx).await + } + + /// OPA negative: `Auditor` role denied `Destroy` — not in `auditor_ops`. + /// + /// The `auditor_ops` set in `kms.rego` grants read-only metadata access + /// (`Locate`, `Get`, `GetAttributes`, …). `Destroy` is a key-lifecycle + /// operation reserved for `CryptoOfficer`/`DomainAdmin`. Even with a valid + /// JWT and the `Auditor` role, OPA returns `allow = false`. + /// + /// Ref: kms.rego `auditor_ops` set (NIST SP 800-53 AU-9 separation-of-duties; + /// PCI-DSS v4.0 Req 10 — auditor must not be able to erase evidence). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auditor_destroy_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_auditor_destroy_denied", + ctx, + ) + .await + } + + /// OPA positive: `Auditor` role allowed `GetAttributes` on a non-owned key. + /// + /// `GetAttributes` IS in `auditor_ops` and the Auditor's domain matches the + /// key's domain (`kms-opa-test`). OPA returns `allow = true` without the + /// Auditor owning the key or having been granted access by the owner. + /// This validates the domain-scoped read-only path end-to-end. + /// + /// Ref: kms.rego `auditor_ops` set; NIST SP 800-57 Part 2 §4.3. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auditor_get_attributes_allowed() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_auditor_get_attributes_allowed", + ctx, + ) + .await + } + + /// OPA negative: `DomainAdmin` in `kms-opa-other` denied access to a key + /// that belongs to `kms-opa-test`. + /// + /// `DomainAdmin` has full control — but only within their own domain + /// (the `same_domain` helper fails when `user_domain != object_domain`). + /// This proves domain isolation holds even for the most privileged non-super role. + /// + /// Ref: kms.rego `DomainAdmin` rule (ANSI/INCITS 359-2004 §4.2 Constrained RBAC; + /// NIST SP 800-53 Rev 5 AC-6 least privilege). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_domain_admin_wrong_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_domain_admin_wrong_domain", + ctx, + ) + .await + } + + /// OPA positive: multi-tenancy — `CryptoOfficer` in `kms-opa-other` domain + /// can create, retrieve, and destroy their own key within their own domain. + /// + /// Counterpart to `mode_exclusive_wrong_domain`: proves that domain isolation + /// blocks cross-domain access but does NOT block intra-domain operations. + /// The `same_domain` helper succeeds because `user_domain == object_domain == + /// kms-opa-other`. + /// + /// Requires `KMS_OPA_URL`, `KMS_AUTH_SERVER_URL`, and `KMS_TEST_OPA_OTHER_DOMAIN_JWT` + /// (set by `mise test:opa_rbac` / `provision_opa_users.sh`). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_other_domain_allowed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_other_domain_allowed", + ctx, + ) + .await + } + + // ── Multi-tenant isolation matrix ──────────────────────────────────────── + // + // The tests below complete the cross-domain isolation matrix. Each one + // covers a different (role, mode) combination not yet represented above: + // + // ┌──────────────────────────────────┬─────────┬──────────┬──────────────┐ + // │ Scenario │ Role │ OPA mode │ Expected │ + // ├──────────────────────────────────┼─────────┼──────────┼──────────────┤ + // │ auditor_wrong_domain (new) │ Auditor │ excl. │ denied │ + // │ user_wrong_domain (new) │ User │ excl. │ denied │ + // │ enforcing_wrong_domain (new) │ CO │ enforc. │ denied │ + // │ super_admin_cross_domain (new) │ SA │ excl. │ allowed │ + // │ wrong_domain (existing) │ CO │ excl. │ denied │ + // │ domain_admin_wrong (existing) │ DA │ excl. │ denied │ + // │ other_domain_allowed (existing) │ CO │ excl. │ allowed │ + // └──────────────────────────────────┴─────────┴──────────┴──────────────┘ + + /// Multi-tenant isolation: `Auditor` in `kms-opa-test` denied `GetAttributes` + /// on a key that belongs to `kms-opa-other`. + /// + /// Even though `GetAttributes` is in `auditor_ops`, the `same_domain` helper + /// in `kms.rego` fails when `user_domain != object_domain` → OPA denies. + /// + /// Counterpart to `mode_exclusive_auditor_get_attributes_allowed`: proves that + /// auditor read access is correctly bounded by domain. + /// + /// Ref: kms.rego `auditor_ops` + `same_domain` (ANSI/INCITS 359 §4.2; + /// NIST SP 800-53 Rev 5 AC-3, AC-4). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auditor_wrong_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_auditor_wrong_domain", + ctx, + ) + .await + } + + /// Multi-tenant isolation: `User` role in `kms-opa-test` denied `GetAttributes` + /// on a key that belongs to `kms-opa-other`. + /// + /// `GetAttributes` is in `user_ops`, but `same_domain` fails → OPA denies. + /// A compromised tenant's User credential must not be able to discover key + /// material from another domain. + /// + /// Ref: kms.rego `user_ops` + `same_domain` (ANSI/INCITS 359 §4.2; + /// FIPS 140-3 §7.4; NIST SP 800-53 Rev 5 AC-3, AC-4). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_user_wrong_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_user_wrong_domain", + ctx, + ) + .await + } + + /// Multi-tenant isolation in **enforcing** (dual-gate) mode: `CryptoOfficer` + /// from `kms-opa-other` must not access a key created in `kms-opa-test`. + /// + /// Proves that domain isolation is not an exclusive-mode artefact. In + /// enforcing mode both OPA and the native KMS gate must allow. OPA's + /// `same_domain` check fails first → access denied. + /// + /// Ref: kms.rego `same_domain` (ANSI/INCITS 359 §4.2; + /// NIST SP 800-53 Rev 5 AC-3, AC-4, SC-28). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_wrong_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_ENFORCING_ALLOWED, "enforcing").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_enforcing_wrong_domain", ctx).await + } + + /// Multi-tenant isolation: `SuperAdmin` is allowed to `Get` and `Destroy` a + /// key across domain boundaries (positive isolation-bypass test). + /// + /// The `SuperAdmin` rule in `kms.rego` is unconditional — the `same_domain` + /// helper is not invoked. This test proves that cross-domain access is + /// correctly granted to exactly the one role that requires it, while all + /// other roles (CO, DA, Auditor, User) remain domain-scoped. + /// + /// Counterpart to `mode_exclusive_wrong_domain`, `mode_exclusive_domain_admin_wrong_domain`, + /// `mode_exclusive_auditor_wrong_domain`, and `mode_exclusive_user_wrong_domain`: + /// together they form the complete isolation matrix. + /// + /// Ref: kms.rego `SuperAdmin` rule (ANSI/INCITS 359 §4.2 top of the role + /// hierarchy; NIST SP 800-53 Rev 5 AC-6(1); NIST SP 800-57 Part 2 §4.3 + /// Key Management Authority role). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_super_admin_cross_domain() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = + get_or_init_opa_allowed_server(&ONCE_VECTOR_OPA_EXCLUSIVE_ALLOWED, "exclusive").await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac` to provision auth server and OPA" + .to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_super_admin_cross_domain", + ctx, + ) + .await + } + + // ── Auth Verifier bearer-token path (exercises `handle_auth_verifier`) ────── + // + // The tests above all use `jwt_auth_provider` → `handle_jwt` to extract roles + // and domain. The tests below use the `AuthVerifier` middleware path (as + // configured by `[auth_verifier]` in the server TOML), which formerly lost + // `roles` and `domain` because `AuthVerifierClaims` only carried `sub`. + // + // Required env vars (same as the "allowed" variants above, plus SuperAdmin JWT): + // `KMS_OPA_URL` — OPA REST API base URL + // `KMS_AUTH_SERVER_URL` — Cosmian auth server HTTPS URL + // `KMS_TEST_OPA_SUPER_ADMIN_JWT` — SuperAdmin JWT (kms-opa-test realm) + // `KMS_TEST_OPA_OFFICER_JWT` — CryptoOfficer JWT (kms-opa-test realm) + // `KMS_TEST_OPA_AUDITOR_JWT` — Auditor JWT (kms-opa-test realm) + // `KMS_TEST_OPA_USER_ROLE_JWT` — User role JWT (kms-opa-test realm) + + /// Start (or reuse) an OPA-enabled KMS server configured with the `AuthVerifier` + /// bearer-token middleware (not `jwt_auth_provider`). + /// + /// This is the production configuration used when `[auth_verifier]` is set in + /// `opa.toml`. The bearer token is processed by `handle_auth_verifier`, which + /// (after the bug fix) extracts `roles` and `domain` (`as_rid`) from the JWT. + /// + /// Returns `None` when `KMS_OPA_URL`, `KMS_AUTH_SERVER_URL`, or + /// `KMS_TEST_OPA_OFFICER_JWT` is not set. + async fn get_or_init_opa_auth_verifier_server( + cell: &'static OnceCell, + opa_mode: &'static str, + jwt_env: &'static str, + ) -> Result, KmsClientError> { + let Ok(opa_url) = std::env::var("KMS_OPA_URL") else { + return Ok(None); + }; + let Ok(auth_server_url) = std::env::var("KMS_AUTH_SERVER_URL") else { + return Ok(None); + }; + let Ok(owner_jwt) = std::env::var(jwt_env) else { + eprintln!( + "SKIP: {jwt_env} not set — \ + run `mise test:opa_rbac` to provision users and export JWT env vars" + ); + return Ok(None); + }; + + let config_path = crate::test_config_path("auth/plain.toml"); + let ctx = cell + .get_or_try_init(|| { + let opa_url_c = opa_url.clone(); + let auth_url_c = auth_server_url.clone(); + let jwt_c = owner_jwt.clone(); + async move { + crate::start_test_server_with_patch( + &config_path, + move |cfg| { + cfg.opa.opa_url = Some(opa_url_c); + cfg.opa.opa_mode = opa_mode.to_owned(); + // Configure the AuthVerifier middleware — the path under test. + // Use `/public/jwks` (the auth server's JWKS endpoint) as the + // explicit JWKS URI; the default `/.well-known/jwks.json` may not + // be available on the test auth server. + cfg.auth_verifier.auth_verifier_url = Some(auth_url_c.clone()); + cfg.auth_verifier.auth_verifier_jwks_uri = + Some(format!("{auth_url_c}/public/jwks")); + cfg.auth_verifier.auth_verifier_realm = + Some(vec!["kms-opa-test".to_owned()]); + // Accept the self-signed test TLS certificate. + cfg.auth_verifier.auth_verifier_accept_invalid_certs = true; + // Disable the OIDC jwt_auth_provider — we want only the + // AuthVerifier middleware active so bearer tokens are routed + // through `handle_auth_verifier` (the path that was broken). + cfg.idp_auth.jwt_auth_provider = None; + // Disable Google CSE: startup would create a CSE RSA key as the + // default user who has no OPA roles → denied in exclusive/enforcing. + cfg.google_cse_config.google_cse_enable = false; + // Unique SQLite paths per auth_verifier + mode + jwt combination + // so concurrent test suites don't share the same database file. + let tag = format!("opa_av_{opa_mode}_{jwt_env}"); + cfg.db.sqlite_path = + PathBuf::from(format!("/tmp/kms_test_{tag}")); + cfg.workspace.root_data_path = + PathBuf::from(format!("/tmp/kms_test_{tag}_ws")); + }, + crate::TestClientOptions { + http: cosmian_kms_client::reexport::cosmian_http_client::HttpClientConfig { + access_token: Some(jwt_c), + ..Default::default() + }, + send_jwt: false, + send_client_cert: false, + send_api_token: true, + }, + ) + .await + } + }) + .await?; + + Ok(Some(ctx)) + } + + /// Auth Verifier path — OPA exclusive: `CryptoOfficer` can run the full + /// key-lifecycle flow (Create → Get → Destroy) via the `AuthVerifier` middleware. + /// + /// Regression test for the bug where `handle_auth_verifier` did not extract + /// `roles` or `domain` from the JWT, causing OPA to see `input.roles = []` + /// and deny all non-owner operations. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auth_verifier_officer_allowed() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_EXCLUSIVE, + "exclusive", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_allowed", ctx).await + } + + /// Auth Verifier path — OPA enforcing: `CryptoOfficer` can run the full + /// key-lifecycle flow via the `AuthVerifier` middleware with enforcing mode + /// (both OPA and native KMS access control must allow). + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_auth_verifier_officer_allowed() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING, + "enforcing", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_enforcing_allowed", ctx).await + } + + /// Auth Verifier path — `SuperAdmin` can create a key in exclusive OPA mode. + /// + /// `SuperAdmin` is the top of the role hierarchy (ANSI/INCITS 359 §4.2): + /// OPA's `allow if { input.roles[_] == "SuperAdmin" }` rule applies regardless + /// of domain. This test verifies that the `auth_verifier` path correctly forwards + /// the `SuperAdmin` role to OPA so it can make the right decision. + /// + /// This was the failing scenario reported in the bug: a user with role `SuperAdmin` + /// received `401: User does not have create access-right` because `roles` was + /// always `[]` in `handle_auth_verifier`. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auth_verifier_super_admin_allowed() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + // Dedicated cell so the SuperAdmin JWT is used for server init regardless + // of the order in which auth_verifier tests run. + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_SUPER_ADMIN, + "exclusive", + "KMS_TEST_OPA_SUPER_ADMIN_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_allowed", ctx).await + } + + /// Auth Verifier path — `User` role denied `Get` (key export) on a non-owned key. + /// + /// The `user_ops` set in `kms.rego` excludes `Get` to prevent raw key-material + /// export by non-owners. This verifies that the `auth_verifier` path correctly + /// forwards the `User` role so OPA can deny the operation. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auth_verifier_user_role_denied() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_EXCLUSIVE, + "exclusive", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context("test_data/vectors/opa/mode_exclusive_user_role_denied", ctx) + .await + } + + /// Auth Verifier path — `Auditor` role denied `Destroy` on a key. + /// + /// `Destroy` is not in `auditor_ops`. This verifies that the `Auditor` role + /// is correctly forwarded through the `auth_verifier` path to OPA. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_auth_verifier_auditor_destroy_denied() + -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_EXCLUSIVE, + "exclusive", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_auditor_destroy_denied", + ctx, + ) + .await + } + + // ── Enforcing mode: Gate 2 (KMS legacy) no longer re-denies OPA-approved ops ── + + /// OPA enforcing: Auditor (non-owner, same domain) can `GetAttributes` on a key + /// they don't own. + /// + /// Regression test for the bug where in enforcing mode the KMS legacy ownership + /// check (Gate 2) re-denied operations that OPA Gate 1 already approved. + /// Symptom: Web UI Locate page showed all fields as N/A except the UID. + /// + /// After the fix: OPA approval is authoritative for non-HSM objects in enforcing + /// mode; `user_has_permission` returns `Ok(true)` immediately after OPA allows. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_co_get_attributes_allowed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING, + "enforcing", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_enforcing_co_get_attributes_allowed", + ctx, + ) + .await + } + + // ── Negative: enforcing mode — bad/empty JWT roles ─────────────────────── + + /// Start (or reuse) an OPA+cert KMS server for native-CO cert tests. + /// + /// `add_co_users`: when `true`, sets `crypto_officer_users = ["owner.client@acme.com"]` + /// so the cert user is privileged and bypasses OPA in enforcing mode. + /// When `false`, no CO list is set → cert user is not privileged → OPA check runs. + /// + /// Returns `None` when `KMS_OPA_URL` is not set. + async fn get_or_init_opa_native_co_server( + cell: &'static OnceCell, + opa_mode: &'static str, + add_co_users: bool, + db_tag: &'static str, + ) -> Result, KmsClientError> { + let Ok(opa_url) = std::env::var("KMS_OPA_URL") else { + return Ok(None); + }; + + let config_path = crate::test_config_path("auth/cert.toml"); + let ctx = cell + .get_or_try_init(|| { + let opa_url_c = opa_url.clone(); + async move { + crate::start_test_server_with_patch( + &config_path, + move |cfg| { + cfg.opa.opa_url = Some(opa_url_c); + cfg.opa.opa_mode = opa_mode.to_owned(); + if add_co_users { + // Privileged cert CO: KMS bypasses OPA Gate 1 in enforcing mode. + cfg.roles.crypto_officer_users = + Some(vec!["owner.client@acme.com".to_owned()]); + } + cfg.google_cse_config.google_cse_enable = false; + cfg.socket_server.socket_server_start = false; + cfg.db.sqlite_path = + PathBuf::from(format!("/tmp/kms_test_opa_{db_tag}")); + cfg.workspace.root_data_path = + PathBuf::from(format!("/tmp/kms_test_opa_{db_tag}_ws")); + }, + crate::TestClientOptions::default(), + ) + .await + } + }) + .await?; + + Ok(Some(ctx)) + } + + /// OPA enforcing: empty JWT roles deny Create. + /// + /// A bearer token with `roles: []` (no role assigned) is sent. OPA evaluates + /// no allow rule → deny. Proves that a misconfigured or role-free token cannot + /// bypass Gate 1 in enforcing mode. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_empty_roles_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING, + "enforcing", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_enforcing_empty_roles_denied", + ctx, + ) + .await + } + + /// OPA enforcing: unknown role denies Create. + /// + /// A bearer token with `roles: ["Hacker"]` is sent. No allow rule in kms.rego + /// matches this role name → deny. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_unknown_role_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING, + "enforcing", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_enforcing_unknown_role_denied", + ctx, + ) + .await + } + + /// OPA enforcing: Auditor role denied Create. + /// + /// `Create` is not in `auditor_ops` (auditors are read-only). Even in enforcing + /// mode, OPA Gate 1 blocks the operation before KMS Gate 2 is reached. + #[tokio::test] + #[ignore = "requires OPA + auth server: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_auditor_create_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_auth_verifier_server( + &ONCE_VECTOR_OPA_AUTH_VERIFIER_ENFORCING, + "enforcing", + "KMS_TEST_OPA_OFFICER_JWT", + ) + .await? + else { + return Err(KmsClientError::Default( + "required env vars not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_enforcing_auditor_create_denied", + ctx, + ) + .await + } + + // ── Cert-auth native KMS CO: exclusive denied / enforcing allowed ───────── + + /// OPA exclusive: native KMS CO (cert, not in `crypto_officer_users`) denied Create. + /// + /// The server is configured WITHOUT `crypto_officer_users`. The mTLS cert client + /// has no JWT → OPA receives `input.roles = []` → deny. OPA is the sole authority + /// in exclusive mode; the KMS privilege bypass does not apply. + #[tokio::test] + #[ignore = "requires OPA: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_exclusive_native_co_cert_denied() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_native_co_server( + &ONCE_VECTOR_OPA_EXCLUSIVE_NATIVE_CO_DENIED, + "exclusive", + false, // no crypto_officer_users → cert user is not privileged + "exclusive_native_co_denied", + ) + .await? + else { + return Err(KmsClientError::Default( + "KMS_OPA_URL not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_exclusive_native_co_cert_denied", + ctx, + ) + .await + } + + /// OPA enforcing: native KMS CO (cert, in `crypto_officer_users`) allowed Create. + /// + /// The server is configured with `crypto_officer_users = ["owner.client@acme.com"]`. + /// The privileged cert user bypasses OPA Gate 1 (KMS native trust in enforcing mode), + /// then passes Gate 2 as the object owner → Create succeeds. + /// + /// Counterpart to `test_vec_opa_mode_exclusive_native_co_cert_denied`: shows that + /// the same cert user IS allowed in enforcing mode when explicitly privileged. + #[tokio::test] + #[ignore = "requires OPA: run via `mise test:opa_rbac`"] + async fn test_vec_opa_mode_enforcing_native_co_cert_allowed() -> Result<(), KmsClientError> { + crate::init_test_logging(); + let Some(ctx) = get_or_init_opa_native_co_server( + &ONCE_VECTOR_OPA_ENFORCING_NATIVE_CO_ALLOWED, + "enforcing", + true, // crypto_officer_users set → cert user IS privileged + "enforcing_native_co_allowed", + ) + .await? + else { + return Err(KmsClientError::Default( + "KMS_OPA_URL not set — run `mise test:opa_rbac`".to_owned(), + )); + }; + run_test_vector_with_context( + "test_data/vectors/opa/mode_enforcing_native_co_cert_allowed", + ctx, + ) + .await + } } diff --git a/docker-compose.yml b/docker-compose.yml index 1d71e97627..24633f9a8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -138,6 +138,70 @@ services: # Minimal OTEL stack for integration tests: # KMS -> otel-collector -> scrape collector Prometheus endpoint + opa: + image: openpolicyagent/opa:edge-static-debug + ports: + - 8181:8181 + volumes: + # Policy file (package kms) + # Roles are carried in the JWT `roles` claim (RFC 9068) and domain in `as_domain`. + - ./test_data/opa/kms.rego:/policies/kms.rego:ro + command: + - run + - --server + - --log-level=info + - --addr=0.0.0.0:8181 + - /policies/kms.rego + + # Authentication Verifier — JWT issuer for KMS OPA RBAC testing. + # + # Prerequisites (run once from the authentication/ directory): + # cargo build -p auth_verifier + # + # The binary is mounted from the local build output. + # The kms_opa_rbac.toml config declares the five roles that match kms.rego. + # + # Endpoints: + # GET https://localhost:8443/public/jwks — JWKS (KMS validates JWTs here) + # GET https://localhost:8443/public/roles — lists the five KMS RBAC roles + # POST https://localhost:8443/login?realm=kms — obtain a JWT for a realm user + # POST https://localhost:8443/admins/realms — create a realm (admin session) + # POST https://localhost:8443/realms/kms/userpass — create a user (admin session) + auth-verifier: + profiles: + - auth-verifier + image: debian:bookworm-slim + ports: + - 8443:8443 + volumes: + # Pre-built binary (build with: cd authentication && cargo build -p auth_verifier) + - ./authentication/target/debug/auth_verifier:/usr/local/bin/auth_verifier:ro + # KMS OPA RBAC configuration with roles matching test_data/opa/kms.rego + - ./test_data/configs/auth_verifier/kms_opa_rbac.toml:/config/kms_opa_rbac.toml:ro + # TLS certificates (shared with the authentication submodule test suite) + - ./authentication/server/src/tests/certificates/ec:/certs:ro + # Persistent SQLite database (survives container restarts) + - auth_verifier_data:/data + environment: + - RUST_LOG=auth_verifier=info + # Override cert paths to absolute paths as seen inside the container + command: > + /bin/sh -c " + sed + -e 's|server/src/tests/certificates/ec/|/certs/|g' + -e 's|connection_url = \"sqlite:///tmp/kms_opa_rbac_auth.db\"|connection_url = \"sqlite:///data/kms_opa_rbac_auth.db\"|' + /config/kms_opa_rbac.toml > /tmp/auth_verifier_runtime.toml && + /usr/local/bin/auth_verifier /tmp/auth_verifier_runtime.toml + " + healthcheck: + test: + - CMD-SHELL + - curl -sk https://localhost:8443/public/version | grep -q version || exit 1 + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + otel-collector: image: otel/opentelemetry-collector-contrib:0.144.0 command: [--config=/etc/otel-collector-config.yaml] @@ -547,6 +611,7 @@ services: fi volumes: + auth_verifier_data: spire-data-a: spire-data-b: spire-agent-socket-a: diff --git a/documentation/book.toml b/documentation/book.toml index 88a17ba867..4ceba3ef0b 100644 --- a/documentation/book.toml +++ b/documentation/book.toml @@ -23,6 +23,9 @@ enable = true enable = true level = 1 +[preprocessor.tabs] +command = "python3 ./theme/scripts/mdbook_tabs.py" + [preprocessor.admonish_compat] command = "python3 ./theme/scripts/mdbook_admonish_compat.py" before = ["admonish"] diff --git a/documentation/docs/SUMMARY.md b/documentation/docs/SUMMARY.md index d091e8a95f..594ae087f6 100644 --- a/documentation/docs/SUMMARY.md +++ b/documentation/docs/SUMMARY.md @@ -3,13 +3,13 @@ - [Why use the Eviden KMS](index.md) - [Quick start](quick_start.md) - [Use cases]() - - [Encrypting and decrypting at scale](use_cases/encrypting_and_decrypting_at_scale.md) - - [Client-side and application-level encryption](use_cases/client_side_and_application_level_encryption.md) - - [Anonymization](use_cases/anonymization.md) + - [Encrypting and decrypting at scale](use_cases/encrypting_and_decrypting_at_scale.md) + - [Client-side and application-level encryption](use_cases/client_side_and_application_level_encryption.md) + - [Anonymization](use_cases/anonymization.md) - [PKI Support]() - - [Introduction](use_cases/pki.md) - - [Revocation & CRL Distribution](use_cases/pki-revocation.md) - - [OCSP Responder](use_cases/pki-ocsp.md) + - [Introduction](use_cases/pki.md) + - [Revocation & CRL Distribution](use_cases/pki-revocation.md) + - [OCSP Responder](use_cases/pki-ocsp.md) - [HSM support]() - [Introduction](hsm_support/introduction/index.md) - [HSM keys & operations](hsm_support/hsm_operations.md) @@ -88,19 +88,25 @@ - [Deploying in a Cosmian Confidential VM](installation/marketplace_guide.md) - [High-availability](installation/high_availability_mode.md) - [Configuration]() - - [Configuration file](configuration/server_configuration_file.md) - - [Configuration examples](configuration/configurations.md) - - [Command line arguments](configuration/server_cli.md) + - [Server reference]() + - [Configuration file](configuration/server_configuration_file.md) + - [Configuration examples](configuration/configurations.md) + - [Command line arguments](configuration/server_cli.md) - [Databases]() - [Configuration](configuration/database/configuration.md) - - [Internals: Database Tables](configuration/database/tables.md) + - [Tables](configuration/database/tables.md) - [Redis with Findex](configuration/database/redis.md) - [Object & Unwrapped Caches](configuration/object-cache.md) - [PKCE Authentication](configuration/pkce_authentication.md) - [Authenticating users to the server](configuration/authentication.md) - - [Authorizing users with access rights]() - - [Ownership and access rights](configuration/authorization.md) - - [Role Management and Key Ceremony](configuration/authorization/key_ceremony.md) + - [Authorization](configuration/authorization/index.md) + - [Mode 1 — Native KMS permissions]() + - [Native KMS permissions](configuration/authorization/mode1.md) + - [Role management and key ceremony](configuration/authorization/key_ceremony.md) + - [Mode 2 — Exclusive OPA (RBAC)](configuration/authorization/mode2.md) + - [Mode 3 — Enforcing (OPA + KMS)]() + - [Architecture and use cases](configuration/authorization/mode3.md) + - [OPA + Authentication Verifier setup](configuration/authorization/opa-authverifier-setup.md) - [Enabling TLS](configuration/tls.md) - [Obtaining TLS Certificates](configuration/certificates.md) - [Logging and telemetry]() @@ -114,16 +120,16 @@ - [Custom OpenSSL build](configuration/openssl_override.md) - [Secret backends](configuration/secret_backends.md) - [Certifications and compliance]() - - [FIPS 140-3](certifications_and_compliance/fips.md) - - [Cryptographic algorithms]() - - [Algorithms](certifications_and_compliance/cryptographic_algorithms/algorithms.md) - - [KMIP algorithm policy](certifications_and_compliance/cryptographic_algorithms/kmip_policy.md) - - [Zeroization](certifications_and_compliance/zeroization.md) - - [Audit]() - - [SBOM](certifications_and_compliance/audit/sbom.md) - - [CBOM](certifications_and_compliance/audit/cbom.md) - - [Security Audit (OWASP)](certifications_and_compliance/audit/owasp_security_audit.md) - - [Multi-Framework Security Audit](certifications_and_compliance/audit/multi_framework_security_audit.md) + - [FIPS 140-3](certifications_and_compliance/fips.md) + - [Cryptographic algorithms]() + - [Algorithms](certifications_and_compliance/cryptographic_algorithms/algorithms.md) + - [KMIP algorithm policy](certifications_and_compliance/cryptographic_algorithms/kmip_policy.md) + - [Zeroization](certifications_and_compliance/zeroization.md) + - [Audit]() + - [SBOM](certifications_and_compliance/audit/sbom.md) + - [CBOM](certifications_and_compliance/audit/cbom.md) + - [Security Audit (OWASP)](certifications_and_compliance/audit/owasp_security_audit.md) + - [Multi-Framework Security Audit](certifications_and_compliance/audit/multi_framework_security_audit.md) - [KMIP Support]() - [Introduction](kmip_support/introduction/index.md) - [KMIP support summary](kmip_support/support.md) @@ -165,12 +171,12 @@ - [Reports](benchmarks/ckms_bench/report.md) - [CPU Scaling & Flamegraphs](benchmarks/cpu_scaling.md) - [KMS Clients]() - - [Getting started](kms_clients/index.md) - - [Installation](kms_clients/installation.md) - - [Configuration]() - - [Authentication](kms_clients/authentication.md) - - [Examples](kms_clients/configuration.md) - - [Usage]() - - [Command Line Interface](kms_clients/usage.md) - - [Access Rights](kms_clients/authorization.md) - - [S/MIME Gmail](kms_clients/smime_gmail.md) + - [Getting started](kms_clients/index.md) + - [Installation](kms_clients/installation.md) + - [Configuration]() + - [Authentication](kms_clients/authentication.md) + - [Examples](kms_clients/configuration.md) + - [Usage]() + - [Command Line Interface](kms_clients/usage.md) + - [Access Rights](kms_clients/authorization.md) + - [S/MIME Gmail](kms_clients/smime_gmail.md) diff --git a/documentation/docs/adr/2026-06-24-rbac-opa-authorization.md b/documentation/docs/adr/2026-06-24-rbac-opa-authorization.md new file mode 100644 index 0000000000..db12010fc4 --- /dev/null +++ b/documentation/docs/adr/2026-06-24-rbac-opa-authorization.md @@ -0,0 +1,269 @@ +--- +title: "ADR-2026-06-24: RBAC Authorization Model with OPA Sidecar" +status: "Accepted" +date: "2026-06-24" +authors: "Cosmian Engineering" +tags: ["architecture", "decision", "security", "rbac", "opa", "authorization", "multi-tenant"] +supersedes: "" +superseded_by: "" +--- + +## Status + +**Accepted** — merged on branch `rbac_rego`, [PR #998](https://github.com/Cosmian/kms/pull/998) + +## Context + +### Prior state + +The Cosmian KMS has always enforced a per-object, per-user, per-operation grant +table stored in the KMS database (the *legacy permission layer*). Every managed +object carries an owner, and other users may be granted specific KMIP operations +via explicit `AddAccess` / `RevokeAccess` calls. The owner always has full +access to their own objects. + +This model has two structural gaps for enterprise deployments: + +1. **No role-based abstractions.** Access is granted object-by-object. + There is no concept of a *role* that covers many objects at once. +2. **No central policy enforcement.** Policy lives only in the KMS database; + auditors, SOC teams, and governance tooling cannot inspect or override it + without calling KMS-specific APIs. + +### Requirements driving this ADR + +| Requirement | Detail | +|---|---| +| **Role-based decisions** | Support CryptoOfficer, Auditor, User, DomainAdmin, SuperAdmin roles out of the box, with role semantics expressible in a human-readable policy file. | +| **Dynamic roles** | Role names and the operations they permit must be changeable at runtime without restarting the KMS server. Role vocabulary must **not** be hardcoded in the KMS binary or configuration file. | +| **Domain isolation** | Keys belong to a *domain* (a tenant identifier). Most roles are constrained to their own domain; only a SuperAdmin is cross-domain. | +| **Separation of duties** | Auditor and CryptoOfficer must be mutually constrainable — enforced in policy, not in code. | +| **Audit trail** | Every access decision must be attributable to a policy rule, visible in OPA's structured logs. | +| **Backward compatibility** | Operators who do not configure OPA must see no behavior change. | +| **Fail-closed** | Any failure in the authorization path (network, parse error, OPA timeout) must result in denial, not approval. | + +### Constraints + +- The KMS is Actix-web 4.x, async/multi-threaded Tokio runtime. +- Roles reach the KMS as JWT claims from an external Identity Provider (IdP) + or Authentication Server; the KMS must not hard-code role names. +- The KMIP 2.1 specification does **not** define user authorization roles. + The five roles adopted here are drawn from FIPS 140-3 §7.4, NIST SP 800-57 + Part 2 §4.3, and ANSI/INCITS 359-2004 (RBAC standard). + +## Decision + +### OPA as an authorization sidecar + +[Open Policy Agent (OPA)](https://www.openpolicyagent.org/) is deployed as a +sidecar process alongside the KMS server. The KMS calls OPA over its REST Data +API (`POST /v1/data/kms/allow`) for every access-control decision. + +**Why a sidecar, not an embedded library?** + +- Policy files (`.rego`) are human-readable and version-controlled independently + of the Rust binary. +- Operators can reload policy without restarting the KMS. +- OPA's decision log (`--log-level=info`) produces a structured audit trail + independent of KMS logs. +- A sidecar allows OPA to hold its own data documents (role assignments, domain + maps) pushed via the OPA Data API — the KMS never needs to store role data. + +### Three evaluation modes + +Three modes are supported, selected via `--opa-url` (enables OPA) and +`--opa-mode`: + +| Mode | `KMS_OPA_MODE` | Behavior | +|---|---|---| +| **Disabled** | *(absent `--opa-url`)* | OPA is not called; only legacy DB grants decide. | +| **Exclusive** | `exclusive` | OPA is the sole decision maker; the legacy DB grant table is not consulted. Suitable for greenfield deployments that manage all access through policy. | +| **Enforcing** | `enforcing` *(default)* | OPA runs first. If OPA denies → deny immediately. If OPA allows → the legacy DB grant check also runs for operations on existing objects (belt-and-suspenders). For object-creation operations (`Create`, `CreateKeyPair`, `Import`, `Register`) OPA's approval is sufficient because no DB grant exists yet. | + +`Enforcing` is the recommended production mode: it layers OPA policy on top of +the existing fine-grained grant model without discarding it. + +### Input document + +The KMS sends the following JSON document to OPA with every evaluation request: + +```json +{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme", + "roles": ["CryptoOfficer"], + "operation": "create", + "object_uid": "*", + "object_domain": "acme", + "is_owner": false + } +} +``` + +| Field | Source | Notes | +|---|---|---| +| `user` | JWT `sub`, TLS CN, or API-token ID | Authenticated identity; never forged. | +| `user_domain` | JWT `as_domain` / `as_rid` private claim | Empty for non-JWT authentication. | +| `roles` | JWT `roles` claim (RFC 9068 array) | **Never set by KMS config.** Empty for non-JWT auth → fail-closed. | +| `operation` | `KmipOperation::to_string()` | Lowercase snake_case KMIP operation name (e.g. `"create"`, `"decrypt"`). | +| `object_uid` | Target object UID | `"*"` for object-less operations. | +| `object_domain` | Owner's domain stored with the object | Empty / equals `user_domain` for object-less operations. | +| `is_owner` | `user == object.owner()` | Owners always receive access regardless of role. | + +**Key invariant**: The KMS is role-vocabulary-agnostic. It forwards whatever +role strings the JWT carries and lets Rego interpret them. Adding a new role +(e.g. `"DataEngineer"`) requires only a Rego change, not a KMS change or +restart. + +### Default Rego policy (`test_data/opa/kms.rego`) + +The repository ships a reference policy implementing five standard roles: + +| Role | Scope | Permitted operations | Normative source | +|---|---|---|---| +| `SuperAdmin` | Global (cross-domain) | All operations | ANSI/INCITS 359-2004 §4.2 | +| `DomainAdmin` | Own domain | All operations | ANSI/INCITS 359-2004 §4.2 | +| `CryptoOfficer` | Own domain | Key lifecycle: `create`, `create_key_pair`, `import`, `get`, `export`, `locate`, `get_attributes`, `set_attribute`, `modify_attribute`, `delete_attribute`, `add_attribute`, `activate`, `revoke`, `archive`, `recover`, `destroy`, `rekey`, `rekey_key_pair` | FIPS 140-3 §7.4; NIST SP 800-57 Part 2 §4.3 | +| `Auditor` | Own domain | Read-only: `locate`, `get`, `get_attributes`, `list_access`, `query_access`, `mac_verify` | NIST SP 800-57 Part 2 §4.3; NIST SP 800-53 AU-9 | +| `User` | Own domain | Crypto-use only: `encrypt`, `decrypt`, `sign`, `verify`, `mac`, `mac_verify`, `derive_key`, `locate`, `get_attributes` | FIPS 140-3 §7.4; PKCS#11 v3.0 | + +Owners always have full access to their objects, regardless of role. + +Operators may supply their own Rego file; the default policy is a starting +point, not a requirement. + +### Fail-closed design + +Any error condition in the OPA call path results in *denial*: + +- Network timeout (5 s hardcoded in `OpaClient`) +- HTTP non-2xx from OPA +- JSON parse failure +- OPA returns `{"result": null}` (undefined policy) + +The decision is `Ok(false)` in all these cases. The KMS never silently grants +access when authorization state is unknown. + +### Task-local context propagation + +The authenticated user's roles and domain are extracted by the auth middleware +and stored in a `tokio::task_local!` variable (`OPA_USER_CONTEXT`). Every +async operation within the HTTP request's task scope reads this context when +building the OPA input document. + +`thread_local!` was explicitly rejected because: + +- Tokio's multi-threaded scheduler migrates tasks across OS threads at every + `.await` point. +- A `thread_local!` value set before an `.await` may be invisible — or belong to + a *different* request — when the task resumes on another thread. + +`tokio::task_local!` (backed by Tokio's `task_local!` macro) is scoped to the +logical async task and survives `.await` migration safely. + +## Consequences + +### Positive + +- **POS-001 Dynamic policy**: Operators can update role definitions and reload OPA + (`SIGHUP` or bundle polling) without restarting the KMS. +- **POS-002 Role-vocabulary independence**: KMS config carries no role strings. + Role names, operations, and domain constraints are entirely OPA's domain. +- **POS-003 Audit trail**: OPA's decision log (`/v1/data/kms/reason`) provides a + per-request, policy-attributed audit record independently of KMS logs. +- **POS-004 Backward compatibility**: `Disabled` mode (no `--opa-url`) leaves + existing deployments completely unchanged. +- **POS-005 Belt-and-suspenders in `Enforcing` mode**: Both OPA policy and the + legacy per-object grant table must allow an operation, reducing the risk of + policy misconfiguration silently widening access. +- **POS-006 Separation of duties**: The Auditor / CryptoOfficer SSD constraint is + expressed in the Rego policy comment as a role-assignment-time requirement; + enforcement is policy-level, not hard-coded. + +### Negative + +- **NEG-001 Extra network hop**: Every permission check incurs a local HTTP round-trip + to the OPA sidecar. The 5-second timeout and fail-closed semantics mitigate + risk but do not eliminate latency. OPA should be co-located on the same host + or within the same pod/container group. +- **NEG-002 Role assignment is the authentication server's responsibility**: The KMS + no longer stores or manages role assignments. Roles are issued by the authentication + server as a `roles` array in the JWT (RFC 9068 §2.2.3.1) and forwarded verbatim to OPA + as `input.roles`. OPA itself holds no role data; the Rego policy interprets the role + strings it receives from the JWT. Operators who want to use OPA's Data API + (`PUT /v1/data/`) to store role assignments may do so, but the reference Rego policy + does not require it. This design adds an operational dependency on the authentication + server's user and role management. +- **NEG-003 JWT-only roles**: Non-JWT authentication methods (TLS client certificates, + API tokens) provide no JWT `roles` claim, so `input.roles` is empty. The Rego + policy can grant access to owners or on `is_owner`, but pure role-based rules + will fail-closed for those auth methods unless the policy explicitly handles them. +- **NEG-004 `Enforcing` mode asymmetry**: Object-creation operations bypass the legacy + DB grant check because no object exists yet; all other operations require both + OPA and a DB grant. This asymmetry must be kept in mind when debugging access + denials. + +## Alternatives Considered + +### Embedded OPA Go library via FFI + +- **ALT-001 Description**: Compile OPA as a Go shared library and call it from Rust via FFI. +- **ALT-002 Rejection Reason**: Significant build complexity; cross-language memory management; + not idiomatic in Rust; breaks the FIPS build which does not allow arbitrary C/Go linkage. + +### Role enum in `kms.toml` + +- **ALT-003 Description**: Define allowed roles as an enum or list in the server configuration file. +- **ALT-004 Rejection Reason**: Hard-codes role vocabulary in KMS config; operators cannot rename + roles without a KMS change and restart. Defeats the "dynamic roles" requirement. + +### Static role mapping in database + +- **ALT-005 Description**: Store role-to-permission mappings in the KMS database (e.g. a `roles` table). +- **ALT-006 Rejection Reason**: Same problem as enum-in-config; role changes require a database + migration or admin API call; no independent audit log; no human-readable policy file. + +### Casbin (Rust-native) + +- **ALT-007 Description**: Use the Rust `casbin` crate for policy-based access control. +- **ALT-008 Rejection Reason**: Smaller ecosystem; less operator familiarity; no native audit-log + integration comparable to OPA's; would still require a sidecar model for live policy reload. + +### OPA bundled as in-process Wasm + +- **ALT-009 Description**: Compile the Rego policy to Wasm and evaluate it in-process. +- **ALT-010 Rejection Reason**: Experimental OPA Wasm support does not cover the Data API; + cannot be updated without redeployment; no audit log support. + +## Implementation Notes + +- **IMP-001**: The `OpaClient` uses a 5-second HTTP timeout with fail-closed semantics. + OPA must be co-located (same host or pod) to avoid latency issues. +- **IMP-002**: Domain is stamped on every newly created object from the creator's JWT + `as_domain` / `as_rid` claim via `OpaUserContext`. Existing objects upgraded from + pre-OPA KMS versions will have an empty domain string; the Rego policy must handle + `object_domain == ""` gracefully (e.g., allow owner access regardless of domain). +- **IMP-003**: The `domain` column is added to all database backends (SQLite, PostgreSQL, + MySQL, Redis-findex) via `ALTER TABLE ADD COLUMN domain TEXT NOT NULL DEFAULT ''`. + This migration is applied automatically on server startup. +- **IMP-004**: `enforce_create_permission` explicitly preserves KMS-native CryptoOfficer + access (`crypto_officer.users` list) for the `Create` right even when OPA is active, + so split-key ceremony participants retain their ability to create key shares. +- **IMP-005 Success criteria**: All 15 OPA test vectors in `test_data/vectors/opa/` pass, + covering all five roles × disabled/exclusive/enforcing modes × allow and deny paths. + +## References + +- **REF-001**: [`test_data/opa/kms.rego`](../../test_data/opa/kms.rego) — reference Rego policy +- **REF-002**: [`crate/server/src/core/opa/`](../../crate/server/src/core/opa/) — OPA client, input type, mode enum, task-local context +- **REF-003**: [`crate/server/src/middlewares/jwt/jwt_token_auth.rs`](../../crate/server/src/middlewares/jwt/jwt_token_auth.rs) — JWT domain/roles extraction into `AuthenticatedUser` +- **REF-004**: [`crate/server/src/middlewares/auth_verifier/token.rs`](../../crate/server/src/middlewares/auth_verifier/token.rs) — Auth Verifier JWT domain/roles extraction +- **REF-005**: [`crate/server/src/core/retrieve_object_utils.rs`](../../crate/server/src/core/retrieve_object_utils.rs) — `user_has_permission()` integration point +- **REF-006**: [`test_data/vectors/opa/`](../../test_data/vectors/opa/) — integration test vectors (15 total) +- **REF-007**: FIPS 140-3 §7.4 — CryptoOfficer and User mandatory module roles +- **REF-008**: NIST SP 800-57 Part 2 §4.3 — Key management role definitions +- **REF-009**: ANSI/INCITS 359-2004 §4.2 — Hierarchical + Constrained RBAC +- **REF-010**: NIST SP 800-53 Rev 5 AC-5, AC-6, AU-9 — Separation of duties, least privilege, audit +- **REF-011**: RFC 9068 §2.2.3.1 — `roles` claim in JWT access tokens +- **REF-012**: [PR #998](https://github.com/Cosmian/kms/pull/998) — implementation PR diff --git a/documentation/docs/adr/2026-06-24-two-role-rbac-crypto-officer-operator.md b/documentation/docs/adr/2026-06-24-two-role-rbac-crypto-officer-operator.md index 3361d0769e..c5e8e66865 100644 --- a/documentation/docs/adr/2026-06-24-two-role-rbac-crypto-officer-operator.md +++ b/documentation/docs/adr/2026-06-24-two-role-rbac-crypto-officer-operator.md @@ -30,10 +30,10 @@ bypass, no Auditor or Administrator role of any kind. Three problems drove this decision: 1. **Standards compliance gap.** ISO/IEC 19790:2012 §7.4 (incorporated verbatim by - FIPS 140-3) mandates a Crypto Officer role in a cryptographic module; a User role - (here called Operator) is optional but recommended to separate key-management from - key-use. The `privileged_users` list is an un-named capability bundle with no - normative basis in the FIPS module boundary, creating ambiguity in compliance audits. + FIPS 140-3) mandates exactly two roles in a cryptographic module: **Crypto Officer** + and **User** (here called Operator). The `privileged_users` list is an un-named + capability bundle with no normative basis in the FIPS module boundary, creating + ambiguity in compliance audits. 2. **Permission granularity.** `privileged_users` conflated two distinct concerns in a single undifferentiated list: key-lifecycle capability (Create, Import) and @@ -42,10 +42,9 @@ Three problems drove this decision: operations, and conferred no ownership-bypass for cross-object administration. 3. **No split-key ceremony path.** There was no mechanism to enforce dual control / - split knowledge for key-lifecycle operations — a practice NIST SP 800-57 Part 2 - Rev 1 §3.2.2.7 recommends documenting for organizations that require multi-party - control. Any user listed in `privileged_users` gained full capability immediately, - with no option for a m-of-n quorum activation ceremony at the module boundary. + split knowledge (NIST SP 800-57 Part 2 Rev 1 §4.6) for key-lifecycle operations. + Any user listed in `privileged_users` gained full capability immediately, with no + option for a m-of-n quorum activation ceremony at the module boundary. ## Decision @@ -55,10 +54,10 @@ in any role default to `Operator` (fail-secure per NIST SP 800-57 Part 2 Rev 1 ### Role matrix -| Role | Allowed operations | Ownership bypass | Key material access | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------- | -| `Operator` | Encrypt, Decrypt, Sign, SignatureVerify, MAC, Hash, Locate, GetAttributes, Query | ✗ | ✗ | -| `CryptoOfficer` | Create, CreateKeyPair, Import, Certify, Rekey, RekeyKeyPair, Activate, Revoke, Destroy, Get, Export, SetAttribute, ModifyAttribute, AddAttribute, DeleteAttribute, Locate, GetAttributes | ✓ | ✓ | +| Role | Allowed operations | Ownership bypass | Key material access | +|---|---|---|---| +| `Operator` | Encrypt, Decrypt, Sign, SignatureVerify, MAC, Hash, Locate, GetAttributes, Query | ✗ | ✗ | +| `CryptoOfficer` | Create, CreateKeyPair, Import, Certify, Rekey, RekeyKeyPair, Activate, Revoke, Destroy, Get, Export, SetAttribute, ModifyAttribute, AddAttribute, DeleteAttribute, Locate, GetAttributes | ✓ | ✓ | > **Note — ACL management (`GrantAccess`/`RevokeAccess`/`ListAccesses`)**: these are > custom server routes, not KMIP operations, and are **owner-scoped**. A CO may @@ -70,10 +69,8 @@ in any role default to `Operator` (fail-secure per NIST SP 800-57 Part 2 Rev 1 `CryptoOfficerConfig.require_ceremony = true` defers activation of the ownership bypass until a KMIP `JoinSplitKey` operation completes with all n shares tagged -`x-cosmian-crypto-officer-ceremony`. This enforces dual control / split knowledge at -the module boundary — a Cosmian-designed mechanism, not a NIST-mandated protocol -(SP 800-57 Part 2 Rev 1 documents split knowledge as an organizational practice to -record, not an implementation to prescribe). +`x-cosmian-crypto-officer-ceremony`. This implements NIST SP 800-57 Part 2 +Rev 1 §4.6 (dual control / split knowledge) at the module boundary. **`JoinSplitKey` IS the activation**: when all shares carry `x-cosmian-crypto-officer-ceremony`, the server writes the `crypto_officer_activations` @@ -112,9 +109,8 @@ reference policy fully implements those roles with documented normative referenc ### Positive -- **POS-001**: Alignment with the ISO/IEC 19790:2012 §7.4 / FIPS 140-3 role model — - the mandatory Crypto Officer role plus the optional User role (implemented as - Operator) — simplifies compliance audit evidence. +- **POS-001**: Exact alignment with ISO/IEC 19790:2012 §7.4 / FIPS 140-3 two-role + module model — simplifies compliance audit evidence. - **POS-002**: Configuration is normatively grounded: the new `[roles]` section maps directly to the two FIPS module roles. The former `privileged_users` flat list is replaced by `crypto_officer_users` with explicit, documented permission semantics. @@ -155,9 +151,8 @@ reference policy fully implements those roles with documented normative referenc ceremony mechanism. - **ALT-004 Rejection Reason**: Loses the explicit operation-level separation between key-management (CryptoOfficer) and key-use (Operator), and loses the split-knowledge - activation path for environments that require dual control on key lifecycle - operations (cf. NIST SP 800-57 Part 2 Rev 1 §3.2.2.7 split-knowledge documentation - guidance). + activation path required by NIST SP 800-57 Part 2 Rev 1 §4.6 for environments that + mandate dual control on key lifecycle operations. ### Delegate all role management to OPA @@ -216,13 +211,13 @@ A second ADR (`documentation/docs/adr/2026-07-24-multi-domain-split-key-ceremony in review as of 2026-07-24) extends this decision into a full multi-domain architecture. Key changes that directly affect the artefacts introduced here: -| ADP | Status | Impact on this ADR | -| ------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **ADP-16** | Planned | `[roles]` TOML section removed; CO candidates assigned per-domain in a DB table. Only `KMS_CEREMONY_SECRET` env var survives. IMP-003/IMP-004 migration instructions become a transitional step only. | -| **ADP-20** | **Implemented** | Reconstructed ceremony secret XOR-joined in RAM; reconstructed key stored as KMS object. Secret never stored in cleartext. | -| **ADP-25** | Planned | Server generates the 256-bit ceremony secret internally (random); the operator-supplied `ceremony_secret` TOML field is removed. | -| **ADP-26** | **Scaffolded** | `ceremony_key_id` config field: references a KMS symmetric key as the ceremony sealing key instead of a static hex secret. Enables key rotation and HSM backing. Accepted by the config parser but not yet functional; `ceremony_secret` is required in the meantime. | -| **ADP-3/15** | Planned | CO candidates assigned per-domain; ceremony activates all CO candidates for that domain simultaneously (vs current per-user activation). | +| ADP | Status | Impact on this ADR | +|-----|--------|-------------------| +| **ADP-16** | Planned | `[roles]` TOML section removed; CO candidates assigned per-domain in a DB table. Only `KMS_CEREMONY_SECRET` env var survives. IMP-003/IMP-004 migration instructions become a transitional step only. | +| **ADP-20** | **Implemented** | Reconstructed ceremony secret XOR-joined in RAM; reconstructed key stored as KMS object. Secret never stored in cleartext. | +| **ADP-25** | Planned | Server generates the 256-bit ceremony secret internally (random); the operator-supplied `ceremony_secret` TOML field is removed. | +| **ADP-26** | **Scaffolded** | `ceremony_key_id` config field: references a KMS symmetric key as the ceremony sealing key instead of a static hex secret. Enables key rotation and HSM backing. Accepted by the config parser but not yet functional; `ceremony_secret` is required in the meantime. | +| **ADP-3/15** | Planned | CO candidates assigned per-domain; ceremony activates all CO candidates for that domain simultaneously (vs current per-user activation). | Until that ADR is merged, the `[roles]` TOML section and the `--crypto-officer-users` CLI flag described in IMP-002/IMP-003 remain the authoritative configuration surface. @@ -231,13 +226,12 @@ CLI flag described in IMP-002/IMP-003 remain the authoritative configuration sur - **REF-001**: ISO/IEC 19790:2012 §7.4 — Crypto module role definitions (incorporated by FIPS 140-3) -- **REF-002**: NIST SP 800-57 Part 2 Rev 1 §3.2.2.7 — Split knowledge / dual control - documentation guidance (voluntary for non-federal organizations); §4.8 — Access - control and need-to-know +- **REF-002**: NIST SP 800-57 Part 2 Rev 1 §4.6 — Dual control / split knowledge; + §4.8 — Access control and need-to-know - **REF-003**: PKCS#11 v3.0 — `CKU_SO` (Security Officer) and `CKU_USER` - **REF-004**: ANSI/INCITS 359-2004 §4.2 — Hierarchical and Constrained RBAC models - **REF-005**: `crate/access/src/access.rs` — `Role` enum and `RoleConfig` struct - **REF-006**: `crate/server/src/config/command_line/roles_config.rs` — CLI flags - **REF-007**: `test_data/opa/kms.rego` — Reference OPA policy with full 5-role model (`SuperAdmin`, `DomainAdmin`, `CryptoOfficer`, `Auditor`, `User`) for advanced deployments -- **REF-008**: `documentation/docs/configuration/authorization/key_ceremony.md` — ceremony walkthrough +- **REF-008**: `documentation/docs/configuration/key_ceremony.md` — ceremony walkthrough diff --git a/documentation/docs/configuration/authorization.md b/documentation/docs/configuration/authorization.md deleted file mode 100644 index afbbe65de0..0000000000 --- a/documentation/docs/configuration/authorization.md +++ /dev/null @@ -1,309 +0,0 @@ -# Authorizing users with access rights - -The authorization system in the Eviden Key Management Service (KMS) operates based on two fundamental principles: - -1. **Ownership:** Every cryptographic object has an assigned owner. The ownership is established when an object is - created using any of the following KMIP operations: `Create`, `CreateKeyPair`, or `Import`. As an owner, a user holds - the privilege to carry out all supported KMIP operations on their objects. - -2. **Access rights delegation:** owners can grant access rights, allowing one or more users to perform certain KMIP - operations on an object. When granted such rights, a user can invoke the corresponding KMIP operation on the KMS for - that particular object. The owner retains the authority to withdraw these access rights at any given time. - ---- - -## Table of Contents - -- [Delegable KMIP operations](#delegable-kmip-operations) -- [The Get super-privilege](#the-get-super-privilege) - - [Practical example](#practical-example) -- [Special handling of the Create permission](#special-handling-of-the-create-permission) -- [Privileged users](#privileged-users) - - [Operations gated by the privileged-user restriction](#operations-gated-by-the-privileged-user-restriction) -- [The wildcard user \*](#the-wildcard-user-) -- [HSM keys and authorization](#hsm-keys-and-authorization) - - [Comparison with regular KMS keys](#comparison-with-regular-kms-keys) - - [Who is an HSM admin?](#who-is-an-hsm-admin) - - [Permission evaluation for HSM keys](#permission-evaluation-for-hsm-keys) - - [What can and cannot be delegated](#what-can-and-cannot-be-delegated) -- [Authentication vs. authorization](#authentication-vs-authorization) -- [Typical workflow: per-user keys with limited permissions](#typical-workflow-per-user-keys-with-limited-permissions) - - [Step 1 — Create the key (as admin/owner)](#step-1--create-the-key-as-adminowner) - - [Step 2 — Grant limited permissions](#step-2--grant-limited-permissions) - - [Step 3 — Alice uses the key](#step-3--alice-uses-the-key) - - [Step 4 — Revoke access (if needed)](#step-4--revoke-access-if-needed) -- [Access management endpoints](#access-management-endpoints) -- [Authorization rules summary](#authorization-rules-summary) - ---- - -## Delegable KMIP operations - -Owners can delegate the following KMIP operations to other users via the `grant` and `revoke` endpoints (or the CLI commands `ckms access-rights grant` / `ckms access-rights revoke`): - -| Operation | Description | -| ------------------ | --------------------------------------------------------------- | -| `create` | Create new cryptographic objects (symmetric keys, key pairs, …) | -| `certify` | Issue or renew X.509 certificates | -| `decrypt` | Decrypt ciphertext using a managed key | -| `derive_key` | Derive a new key from an existing key | -| `destroy` | Permanently destroy an object | -| `encrypt` | Encrypt plaintext using a managed key | -| `export` | Export an object (key material + metadata) from the KMS | -| `get` | Retrieve an object — **this is a super-privilege** (see below) | -| `get_attributes` | Read the KMIP attributes of an object | -| `hash` | Compute a cryptographic hash | -| `import` | Import an external object into the KMS | -| `locate` | Search for objects matching given attributes | -| `mac` | Compute a Message Authentication Code | -| `revoke` | Revoke (deactivate) an object | -| `rekey` | Re-key an existing symmetric key | -| `sign` | Generate a digital signature | -| `signature_verify` | Verify a digital signature | -| `validate` | Validate a certificate chain | -| `set_attribute` | Set (replace) an attribute on an object | -| `modify_attribute` | Modify an existing attribute on an object | -| `add_attribute` | Add a new attribute value to an object | -| `delete_attribute` | Remove an attribute from an object | - -Multiple operations can be granted or revoked in a single call. For example, using the CLI: - -```bash -# Grant encrypt and decrypt to user "alice" -ckms access-rights grant alice -i encrypt decrypt - -# Revoke the get privilege from user "bob" -ckms access-rights revoke bob -i get -``` - -## The `Get` super-privilege - -The `Get` operation has a special role in the permission model: **it acts as a super-privilege that implies every other -object-level operation**. - -When checking whether a user is authorized to perform a given operation on an object, the KMS evaluates the following -rules in order: - -1. **Owner check** — if the requesting user is the owner of the object, access is always granted. -2. **Explicit permission** — if the user has been explicitly granted the requested operation (e.g. `encrypt`), access is - granted. -3. **`Get` fallback** — if the user holds the `Get` permission on the object, access is granted **regardless of the - specific operation requested**. - -In other words, granting `Get` to a user on an object is equivalent to granting that user `encrypt`, `decrypt`, -`export`, `sign`, `derive_key`, and every other object-level operation — except lifecycle operations (`revoke`, -`destroy`) which still require their own explicit grant. - -This design allows owners to share full read/use access to an object with a single permission, without individually -enumerating every operation. - -!!! warning Security implication - Because `Get` implies all other operation-level permissions, it should be granted with care. - If you only need a user to encrypt data with a key, grant `encrypt` — not `get`. - -### Practical example - -| Granted permissions | Can the user `encrypt`? | Can the user `export`? | Can the user `destroy`? | -| -------------------- | :---------------------: | :--------------------: | :---------------------: | -| `encrypt` | Yes | No | No | -| `get` | Yes | Yes | No | -| `encrypt`, `destroy` | Yes | No | Yes | -| `get`, `destroy` | Yes | Yes | Yes | - -!!! note - The `destroy` and `revoke` operations are **never** implied by `get`. They must always be granted explicitly - because they are irreversible lifecycle transitions. - -## Special handling of the `Create` permission - -The `Create` operation is not bound to a specific object — it controls whether a user is allowed to create _new_ objects -in the KMS. Internally it is stored against the wildcard object identifier `*`. - -- When granting or revoking `create`, no object UID is required. -- `Create` can be combined with object-level operations in the same request; the server will separate and process them - accordingly. - -## Privileged users - -By default, all users are allowed to create or import objects in the KMS. - -However, when the KMS server is configured with a list of privileged users, object creation rights are restricted as follows: - -- Privileged users can create or import objects and are authorized to grant or revoke object creation permissions for other users. -- Regular users cannot create or import objects unless they have explicitly been granted permission by a privileged user. -- Regular users cannot grant or revoke creation permissions for others. -- Privileged users cannot revoke object creation permissions from other privileged users. - -### Operations gated by the privileged-user restriction - -Because the following operations all result in a **new cryptographic object** being created in the KMS, they are all -subject to the same privileged-user check: - -| Operation | Reason | -| --------------- | ---------------------------------------------------- | -| `Create` | Creates a new symmetric key or secret data object | -| `CreateKeyPair` | Creates a new asymmetric key pair | -| `Import` | Imports an external object into the KMS | -| `Register` | Registers an externally-generated object | -| `Certify` | May create a new key pair when issuing a certificate | -| `ReKey` | Creates a new replacement symmetric key | -| `ReKeyKeyPair` | Creates a new replacement asymmetric key pair | - -!!! note - `ReKey` and `ReKeyKeyPair` also require the caller to hold the `Rekey` permission on the existing key being - re-keyed. Both conditions must be satisfied: the user must be allowed to create new objects **and** be allowed - to rekey the specific existing key. - -## The wildcard user `*` - -!!! important "The Wildcard User: *" - In addition to regular users, a special user called `*` (the wildcard user) can be used to grant access rights on - objects to **all** users. When a permission is granted to `*`, every authenticated user benefits from that permission - on the targeted object. Individual per-user grants are merged with the wildcard grants when evaluating access. - -## HSM keys and authorization - -Keys stored in an HSM follow a **stricter permission model** than regular KMS keys. -Authorization metadata (owner, grants) is still managed by the KMS, but two -important differences apply. - -### Comparison with regular KMS keys - -| Aspect | KMS keys | HSM keys | -| ------------------------------ | -------------------------------------------- | -------------------------------------------------- | -| Key material stored in | KMS database (encrypted) | HSM hardware | -| `Get` is a super-privilege | Yes — implies all operations | **No** — each operation must be granted explicitly | -| `Get` ↔ `Export` equivalence | No | **Yes** — holding either grants both | -| `Destroy` / `Revoke` delegable | Yes | **No** — blocked; admin-only | -| `Create` | Any user (or privileged users if configured) | HSM admin only | -| `Locate` visibility | All owned / granted objects | Non-admins see only keys with ≥ 1 explicit grant | - -### Who is an HSM admin? - -Users listed in the server's `hsm_admin` configuration for a given HSM instance are -its **admins**. Admins bypass all permission checks for that HSM — they can create, -destroy, and perform any operation on its keys. - -### Permission evaluation for HSM keys - -```text -Request arrives for operation OP on key hsm:::::: -│ -├─ Is the user an HSM admin for this instance? ──▶ YES → Granted -│ -├─ Does the user have OP explicitly granted? ──────▶ YES → Granted -│ -├─ Is OP = Export and user has Get? ───────────────▶ YES → Granted -├─ Is OP = Get and user has Export? ─────────────▶ YES → Granted -│ -└─ Otherwise ──────────────────────────────────────────────▶ Denied -``` - -### What can and cannot be delegated - -| Operation | Delegable via `grant`? | Notes | -| ------------------------------------------------------------------------ | :--------------------------: | -------------------------------------------------------- | -| `encrypt`, `decrypt`, `sign`, `mac` | Yes | All standard cryptographic operations | -| `get` | Yes | Also implies `export` (equivalence) | -| `export` | Yes | Also implies `get` (equivalence) | -| `get_attributes`, `locate` | Yes | | -| `set_attribute`, `modify_attribute`, `add_attribute`, `delete_attribute` | Yes | Operate on KMS metadata only; do not access HSM hardware | -| `create` | Yes (admin to another admin) | Non-admin cannot receive `create` on HSM | -| `destroy` | **No** | Blocked — admin-only, cannot be delegated | -| `revoke` | **No** | Blocked — HSM objects do not use KMIP lifecycle states | - -!!! warning - Unlike regular KMS keys, **granting `Get` on an HSM key does not imply `encrypt`, - `decrypt`, `sign`, or any other operation**. Each operation must be granted - individually. - -See the [HSM operations](../hsm_support/hsm_operations.md) page for HSM admin -configuration details. - -## Authentication vs. authorization - -It is important to distinguish authentication from authorization: - -- **Authentication** determines _who_ the user is. The KMS supports TLS client certificates, JWT tokens, and API tokens. - See the [Authentication](authentication.md) page for details on how to configure these methods and how user identities - are established. -- **Authorization** determines _what_ an authenticated user is allowed to do with a given cryptographic object. This is - the permission model described on this page. - -!!! tip - An **API token** (used for authentication) is not the same thing as a **symmetric key** stored in the KMS. - The API token proves the user's identity; the symmetric key is a cryptographic object the user may or may not - have permission to use. - -## Typical workflow: per-user keys with limited permissions - -!!! info "Permissions are managed at runtime, not in `kms.toml`" - The `kms.toml` configuration file controls **server-level** settings only (authentication methods, database backend, - TLS, privileged users, etc.). It does **not** contain any user-to-key permission mapping. - Per-object access rights are managed dynamically at runtime through the REST API (`/access/grant`, `/access/revoke`) - or the CLI (`ckms access-rights grant` / `ckms access-rights revoke`). - The only authorization-related setting in `kms.toml` is `privileged_users`, which restricts who can create or import - new objects (see [Privileged users](#privileged-users) above). - -A common deployment pattern is to have an administrator create one symmetric key per user and grant only the -operations each user needs (e.g. `encrypt` and `decrypt`). - -### Step 1 — Create the key (as admin/owner) - -```bash -# The admin creates a 256-bit AES key and tags it for easy lookup -ckms sym keys create --algorithm aes --number-of-bits 256 --tag user-alice-key -``` - -The command returns the key's unique identifier, for example `a]b2c3d4-...`. - -### Step 2 — Grant limited permissions - -```bash -# Grant only encrypt and decrypt to alice (identified by her authenticated username) -ckms access-rights grant alice@example.com -i a]b2c3d4-... encrypt decrypt -``` - -Alice can now encrypt and decrypt using this key, but she **cannot** export it, destroy it, or perform any other -operation on it. - -### Step 3 — Alice uses the key - -Alice authenticates to the KMS (via her client certificate, JWT token, or API token) and calls the encrypt/decrypt -endpoints referencing the key UID. The server verifies she holds the `encrypt` / `decrypt` permission before -proceeding. - -### Step 4 — Revoke access (if needed) - -```bash -ckms access-rights revoke alice@example.com -i a]b2c3d4-... encrypt decrypt -``` - -!!! note - Do **not** grant `get` if you only want to allow encrypt/decrypt — `get` is a super-privilege that implies all - object-level operations (see above). - -## Access management endpoints - -The KMS exposes the following REST endpoints to manage access rights: - -| Method | Endpoint | Description | -| ------ | -------------------------- | --------------------------------------------------------- | -| POST | `/access/grant` | Grant operations on an object to a user | -| POST | `/access/revoke` | Revoke operations on an object from a user | -| GET | `/access/list/{object_id}` | List all access rights granted on an object (owner only) | -| GET | `/access/owned` | List all objects owned by the authenticated user | -| GET | `/access/obtained` | List all access rights obtained by the authenticated user | -| GET | `/access/create` | Check whether the authenticated user can create objects | -| GET | `/access/privileged` | Check whether the authenticated user is privileged | - -## Authorization rules summary - -| Scenario | Access granted? | -| ------------------------------------------------------- | :-------------: | -| User is the object owner | Always | -| User has the exact requested operation granted | Yes | -| User has `Get` granted (any operation except lifecycle) | Yes | -| User has no matching permission | Denied | -| User tries to grant/revoke their own permissions | Denied | -| Non-owner tries to grant permissions | Denied | diff --git a/documentation/docs/configuration/authorization/index.md b/documentation/docs/configuration/authorization/index.md new file mode 100644 index 0000000000..c52be92ab7 --- /dev/null +++ b/documentation/docs/configuration/authorization/index.md @@ -0,0 +1,181 @@ +# Authorization + +The Eviden KMS implements **two independent, composable authorization systems** +that can be used alone or together. + +| System | Decides based on | Configured via | +| ------ | ---------------- | -------------- | +| **Native KMS permissions** | Object ownership + per-user grants | Runtime API (`/access/grant`, `/access/revoke`) and `kms.toml` | +| **OPA RBAC** | JWT roles + domain scoping | Rego policy on an OPA sidecar + Eviden Authentication Server | + +--- + +## Quick-start: pick your mode + +| Mode | Name | When to use | +| :--: | ---- | ----------- | +| **1** | [Native KMS only](mode1.md) | Default. Suitable for single-tenant or air-gapped deployments. | +| **2** | [Exclusive OPA](mode2.md) | Multi-tenant or regulated environments where all access decisions must go through OPA. | +| **3** | [OPA + Native KMS](mode3.md) | Layered security: OPA enforces role policy first, then per-object ownership/grants apply. | + +The OPA modes require the **Eviden Authentication Server** (JWT issuer) and an +**OPA sidecar** running the reference Rego policy. See the +[RBAC, OPA, JWT and IdP setup guide](rbac-opa-jwt-setup.md) for a step-by-step +walkthrough. + +--- + +## CryptoOfficer role and split-key ceremony + +Independent of OPA, the KMS also provides a built-in **CryptoOfficer** role +that satisfies the ISO/IEC 19790:2012 §7.4 / FIPS 140-3 mandatory two-role +model. + +The role can optionally require a **split-key ceremony** (XOR n-of-n, +NIST SP 800-57 Part 2 §4.6 dual control) before the CryptoOfficer gains +unrestricted access: + +→ **[Role management and key ceremony](key_ceremony.md)** + +!!! note "Interaction with OPA modes" + The CryptoOfficer activation ceremony operates exclusively inside the native + KMS gate. In Mode 2 (exclusive OPA) the ceremony has no effect; in Mode 3 + it takes effect only after OPA has allowed the request. + +--- + +## OPA role model + +```mermaid +graph TD + SA["SuperAdmin
cross-domain"] -->|subsumes| DA + DA["DomainAdmin
full access, own domain"] -->|subsumes| CO + DA -->|subsumes| AU + CO["CryptoOfficer
key lifecycle, own domain"] -->|subsumes| US + AU["Auditor
read-only, own domain"] + US["User
crypto-use only, own domain"] +``` + +| Role | Allowed operations | Domain-scoped? | +| ---- | ------------------ | :------------: | +| `SuperAdmin` | All KMIP operations | No | +| `DomainAdmin` | All KMIP operations | **Yes** | +| `CryptoOfficer` | create, import, get, export, locate, get_attributes, set_attribute, modify_attribute, delete_attribute, add_attribute, activate, revoke, archive, recover, destroy, rekey, rekey_key_pair | **Yes** | +| `Auditor` | locate, get, get_attributes, list_access, query_access, mac_verify | **Yes** | +| `User` | encrypt, decrypt, sign, verify, mac, mac_verify, derive_key, locate, get_attributes | **Yes** | + +### Object owner override + +Regardless of role, the object **owner** always has full access. The `is_owner` +flag is computed by the KMS and included in the OPA input. + +--- + +## Architecture overview + +```mermaid +flowchart TB + subgraph AuthPlane["Eviden Authentication Server"] + AuthSrv["Auth Server
password + TOTP"] + end + + subgraph PolicyPlane["Policy Plane"] + OPA["OPA Server
/v1/data/kms/allow"] + Rego["kms.rego"] + OPA -.- Rego + end + + subgraph KMSPlane["Eviden KMS"] + KMS["KMS Server"] + DB[("KMS Database")] + KMS -.- DB + end + + U(["Client"]) + + U -->|"Login"| AuthSrv + AuthSrv -->|"JWT: sub, roles, as_domain"| U + U -->|"KMIP + JWT"| KMS + KMS -->|"OpaInput"| OPA + OPA -->|"allow: true/false"| KMS +``` + +--- + +## Domain model + +Domain-based isolation enforces "within own domain" scoping for `DomainAdmin`, +`CryptoOfficer`, `Auditor`, and `User` roles. + +- **User domain** (`user_domain`) — from the `as_domain` JWT private claim. +- **Object domain** (`object_domain`) — stamped at creation from the creator's + `user_domain`; stored immutably in the `domain` column of the `objects` table. +- **Object-less operations** (e.g. `Create`) — `object_domain` = `user_domain`. + +!!! note "Existing objects (pre-migration)" + Objects created before RBAC deployment have `domain = ""`. They remain + accessible to their owner but invisible to domain-scoped role rules. Only + `SuperAdmin` can access them via the role path. + +--- + +## OPA input document + +On every KMIP operation (in Modes 2 and 3), the KMS sends: + +```json +{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "operation": "create", + "object_uid": "*", + "object_domain": "acme.com", + "is_owner": false + } +} +``` + +| Field | Source | Default | +| ----- | ------ | ------- | +| `user` | JWT `sub` / TLS CN / API-token id | — | +| `user_domain` | JWT `as_domain` | `""` | +| `roles` | JWT `roles` (RFC 9068) | `[]` | +| `operation` | KMIP operation name (snake_case) | — | +| `object_uid` | Target object UID | `"*"` | +| `object_domain` | `objects.domain` column | `user_domain` | +| `is_owner` | `user == object.owner` | `false` | + +Response: `{"result": true}`. Any error or non-`true` value → `false` (fail-closed). + +--- + +## Configuration reference + +```toml +# kms.toml +[opa] +# OPA server base URL. Omit to disable OPA (Mode 1). +opa_url = "http://localhost:8181" + +# "exclusive" → Mode 2; "enforcing" → Mode 3. +opa_mode = "enforcing" +``` + +See the [setup guide](rbac-opa-jwt-setup.md) for Authentication Server and OPA +sidecar configuration. + +--- + +## Normative references + +| Standard | Usage | +| -------- | ----- | +| RFC 7519 | JWT claims (`sub`, `exp`, `iat`, `iss`) | +| RFC 9068 §2.2.3.1 | `roles` as IANA-registered JWT claim | +| RFC 7643 §4.1.2 | SCIM `roles` attribute definition | +| FIPS 140-3 §7.4 | `CryptoOfficer` and `User` mandatory module roles | +| NIST SP 800-57 Part 2 §4.3 | Key management role definitions | +| ANSI/INCITS 359-2004 §4.2 | Hierarchical RBAC model | +| NIST SP 800-53 Rev 5 AC-5, AC-6, AU-9 | Least privilege, separation of duties | diff --git a/documentation/docs/configuration/authorization/key_ceremony.md b/documentation/docs/configuration/authorization/key_ceremony.md index 2855a8ed90..e6bc436980 100644 --- a/documentation/docs/configuration/authorization/key_ceremony.md +++ b/documentation/docs/configuration/authorization/key_ceremony.md @@ -1,44 +1,92 @@ # Role Management and Key Ceremony -Cosmian KMS supports two built-in roles, **Operator** and **CryptoOfficer**, so that -day-to-day cryptographic use (encrypt, sign, decrypt) can be kept separate from -key-lifecycle administration (create, activate, destroy, cross-user access). For -deployments that want extra assurance around who can become a CryptoOfficer, the role -can optionally require a **split-key ceremony**: instead of one person activating the -role alone, the key that grants it is split across several people, and all of them must -cooperate to activate it. +Cosmian KMS implements a two-role **Role-Based Access Control** (RBAC) model drawing on +two normative sources: + +- **[ISO/IEC 19790:2012](https://csrc.nist.gov/pubs/fips/140-3/final)** (adopted by [FIPS 140-3](https://csrc.nist.gov/pubs/fips/140-3/final)) — + defines mandatory Crypto Officer and User roles for cryptographic modules. +- **[NIST SP 800-57 Part 2 Rev 1](https://csrc.nist.gov/pubs/sp/800/57/pt2/r1/final)** — + prescribes split knowledge and dual control for key management. + +The **CryptoOfficer** role can optionally require a *split-key ceremony* for activation +under the principle of *split knowledge* +([NIST SP 800-57 Part 2 Rev 1 §4.6](https://csrc.nist.gov/pubs/sp/800/57/pt2/r1/final)). +Without a ceremony, users in the `crypto_officer_users` list are immediately active. +With a ceremony, the role is **dormant** until a quorum of custodians assembles +all key shares. --- -## Table of Contents - -- [Role Management and Key Ceremony](#role-management-and-key-ceremony) - - [Table of Contents](#table-of-contents) - - [The Officer/Operator two roles model](#the-officeroperator-two-roles-model) - - [Turning on CryptoOfficer](#turning-on-cryptoofficer) - - [Mode 1: Config-only (no ceremony)](#mode-1-config-only-no-ceremony) - - [Mode 2: Split-key ceremony required](#mode-2-split-key-ceremony-required) - - [Walkthrough: a 3-person ceremony](#walkthrough-a-3-person-ceremony) - - [Phase 1: Provisioning](#phase-1-provisioning) - - [Phase 2: Activate Crypto Officer Role (JoinSplitKey)](#phase-2-activate-crypto-officer-role-joinsplitkey) - - [Revoking](#revoking) - - [Emergency revocation (config path)](#emergency-revocation-config-path) - - [Quick reference](#quick-reference) - - [Permission model](#permission-model) - - [Configuration](#configuration) - - [CLI](#cli) - - [REST API equivalents](#rest-api-equivalents) - - [Role store vs. key store](#role-store-vs-key-store) - - [Standards this design draws on](#standards-this-design-draws-on) - - [Related pages](#related-pages) +## Normative foundations + +### XOR-based split knowledge + +The ceremony relies on **$n$-of-$n$ split knowledge**: the master key is split into $n$ +shares using XOR, and *all* $n$ shares are required to reconstruct the secret. +The scheme is information-theoretically secure: any strict subset of shares reveals zero +information about the master key. + +1. A dealer generates $n-1$ uniformly random byte strings, each of the same length $\ell$ as the secret $s$. +2. The final share is the XOR of the secret with all other shares: $r_n = s \oplus r_1 \oplus \cdots \oplus r_{n-1}$. +3. Reconstruction: $s = r_1 \oplus r_2 \oplus \cdots \oplus r_n$ — all shares are required. + +The constraint: $n \ge 3$ (threshold equals total parts, minimum 3 custodians required). + +!!! danger "Why n ≥ 3 is mandatory (not n ≥ 2)" + With only two custodians (Alice and Bob), the scheme provides no real dual control. + The dealer who creates the master key $K$ and retains share $S_1$ can trivially compute + Bob's share: $S_2 = K \oplus S_1$. This means Alice knows both shares from the moment of + creation — Bob's active cooperation is never required. + + With **n ≥ 3** custodians, the dealer knows $K$ and one share $S_1$, but can only compute + $S_2 \oplus S_3 \oplus \cdots \oplus S_n$ — not any individual share. Genuine cooperation + from at least $n-1$ other custodians is always required. + + This follows directly from the information-theoretic security of XOR splitting (see + [NIST SP 800-57 Part 2 Rev 1 §4.6](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt2r1.pdf)). + **The KMS rejects ceremony configuration with fewer than 3 custodians at startup.** + +!!! warning "Ceremony key destroyed after split" + When a key is split for a ceremony (`x-cosmian-crypto-officer-ceremony` attribute), + the server **automatically destroys** the original key after all shares are stored. + This is a defense-in-depth measure: even if the dealer had exported the original key + before splitting, destroying it removes the direct reconstruction path and forces + genuine custodian cooperation from the moment of the ceremony. + +### Role store vs. key store + +**Important security boundary:** + +| Store | Written by | Purpose | +|---|---|---| +| `crypto_officer_activations` DB table | `JoinSplitKey` on ceremony shares, or `POST /access/crypto_officer/ceremony/activate` | **Sole source of truth** for CO role status. Sealed with AES-256-GCM under `ceremony_secret`. | +| `objects` DB table | Every `JoinSplitKey` call (ceremony and non-ceremony) | Stores the reconstructed key as a managed KMS object owned by the caller. For ceremony shares the key is stored **unconditionally** before the activation side-effect runs. | + +!!! info "Two ceremony completion paths" + - **`JoinSplitKey` KMIP operation** (primary path): stores the reconstructed key in `objects` **and** writes the CO activation record. Suitable for clients that need the reconstructed key as a usable KMS object. + - **`POST /access/crypto_officer/ceremony/activate`** (CLI legacy path): reconstructs the secret in RAM only (for hash verification), writes the CO activation record, and **does not store a key object**. + +The `x-cosmian-crypto-officer-ceremony` tag on shares identifies which shares belong to +a ceremony split. **It does NOT grant any privilege.** The server checks this tag only +during ceremony activation validation — never for privilege checks. This prevents: +an attacker calling `Create(key)` + `SetAttribute(x-cosmian-crypto-officer-ceremony=true)` +from escalating to CO role. + +### Design rationale + +| Standard | Relevant area | What it requires | How Cosmian KMS applies it | +|---|---|---|---| +| [NIST SP 800-57 Part 2 Rev 1](https://csrc.nist.gov/pubs/sp/800/57/pt2/r1/final) | Split knowledge (§4.6) | No single entity shall have access to the complete cryptographic key | Split-key ceremony with XOR n-of-n | +| [NIST SP 800-57 Part 2 Rev 1](https://csrc.nist.gov/pubs/sp/800/57/pt2/r1/final) | Dual control (§4.6) | At least two authorised persons required for sensitive key-management operations | All $n$ shares required for ceremony activation | +| [ISO/IEC 19790:2012](https://csrc.nist.gov/pubs/fips/140-3/final) ([FIPS 140-3](https://csrc.nist.gov/pubs/fips/140-3/final)) | Roles, services, and authentication (§7.4) | Mandatory Crypto Officer and User roles; separation between key management and key use | CryptoOfficer (lifecycle + ownership bypass) vs. Operator (crypto use) | --- -## The Officer/Operator two roles model +## The two roles ```mermaid graph TB - subgraph "Role model" + subgraph "Role model (ISO/IEC 19790 §7.4)" CO["🔐 CryptoOfficer
Lifecycle: Create, Import, Certify,
Activate, Revoke, Destroy, ReKey,
Get, Export, Attribute management
+ ownership bypass on all objects
+ all Operator operations (incl. crypto use)"] Op["👤 Operator (default)
Crypto use: Encrypt, Decrypt,
Sign, MAC, Hash,
GetAttributes, Locate, Validate"] end @@ -46,27 +94,29 @@ graph TB CO -. "superset of" .- Op ``` -| Role | Config key | Allowed KMIP operations | Can access other users' objects? | -| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------: | -| **Operator** | _(default, no config key)_ | Encrypt, Decrypt, Sign, SignatureVerify, MAC, Hash, GetAttributes, Locate, Validate | No | -| **CryptoOfficer** | `crypto_officer_users` | **All Operator operations** + Create, Certify, Import, Get, Export, ReKey, DeriveKey, Activate, Revoke, Destroy, Set/Modify/Add/DeleteAttribute, CreateSplitKey, JoinSplitKey | **Yes, ownership bypass** | +| Role | Config key | Allowed KMIP operations | Can access other users' objects? | +|---|---|---|:---:| +| **Operator** | *(default — no config key)* | Encrypt, Decrypt, Sign, SignatureVerify, MAC, Hash, GetAttributes, Locate, Validate | No | +| **CryptoOfficer** | `crypto_officer_users` | **All Operator operations** + Create, Certify, Import, Get, Export, ReKey, DeriveKey, Activate, Revoke, Destroy, Set/Modify/Add/DeleteAttribute, CreateSplitKey, JoinSplitKey | **Yes — ownership bypass** | !!! note "Fail-secure default" When `crypto_officer_users` is configured but a user is not in the list, the server assigns the **Operator** role (minimum privilege). Users are never silently promoted. -!!! warning "Ownership bypass excludes HSM-backed keys" - The CryptoOfficer ownership bypass applies to KMS-managed objects only. Keys stored - in an HSM are **not** covered: access to them stays governed by the HSM's own admin - rules, regardless of CryptoOfficer status. - --- -## Turning on CryptoOfficer +## CryptoOfficer role + +CryptoOfficers may: -There are two ways to grant the CryptoOfficer role, chosen per deployment. +- Create, import, certify, activate, revoke, and destroy objects +- Access raw key material (`Get`, `Export`) — "key output" per ISO/IEC 19790 §7.4 +- Manage object attributes +- **Use keys cryptographically** (`Encrypt`, `Decrypt`, `Sign`, `SignatureVerify`, `MAC`, `Hash`, `Validate`) +- **Access any object** regardless of ownership (bypass per-object permission checks) +- **Locate all objects** (bypasses user filtering in `Locate`) -### Mode 1: Config-only (no ceremony) +### Mode 1 — Config-only (no ceremony) ```toml [roles] @@ -77,7 +127,7 @@ crypto_officer_require_ceremony = false # default `key-mgr@example.com` is a CryptoOfficer on first connection. Suitable when physical security controls or organisational policy already enforce the required trust level. -### Mode 2: Split-key ceremony required +### Mode 2 — Split-key ceremony required ```toml [roles] @@ -92,110 +142,156 @@ ceremony_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abc CryptoOfficer privileges are **inactive** at startup. At least **3** users must be listed in `crypto_officer_users` when `require_ceremony = true` (the server rejects fewer). -Each configured CO candidate activates **independently** by running their own ceremony -(`JoinSplitKey` with a fresh set of shares). Multiple COs can be simultaneously active -at any time — activating your own ceremony does **not** revoke any other currently active -CO. +The role becomes active only after the ceremony completes (KMIP `JoinSplitKey` with all +ceremony-tagged shares). --- -## Walkthrough: a 3-person ceremony - -The diagram below follows a ceremony with three custodians, Alice, Bob, and Carol, -(which is also the minimum the KMS accepts). - -![Split-key ceremony overview](crypto_officer_ceremony.png) +## Ceremony lifecycle -Alice creates the master key and splits it into three shares; the KMS destroys the -original immediately, so from that point on not even Alice can recover it alone. Bob and -Carol each hold a share Alice does not have. To activate the CryptoOfficer role, Alice -needs her own share plus at least one of theirs, which means Bob or Carol must actively -grant her access first, so Alice cannot silently activate on her own. +### Phase 1 — Provisioning -!!! info "Why not just two people?" - With two custodians, whoever creates the key can always compute the other person's - share from the key and their own share, so no real cooperation is required. Three is - the minimum where that shortcut disappears, which is why the KMS rejects fewer than - 3 custodians at startup. +The CO candidate creates an AES key, stamps it with the ceremony marker, splits it into $n$ +shares, and distributes them to custodians. The number of shares is auto-determined by the +server from the `crypto_officer_users` count, and each share is auto-assigned to a different +CO candidate (dual-control enforcement). -### Phase 1: Provisioning - -The number of shares is auto-determined from the `crypto_officer_users` count, and each -share is auto-assigned to a different CO candidate. No server restart is needed: the -ceremony candidate exemption lets a CO candidate call `Create`, `CreateSplitKey`, and -`JoinSplitKey` even before the ceremony completes, which is what breaks the -chicken-and-egg problem of needing the role to set up the role. +No restart is required — the ceremony candidate exemption allows +`Create`, `Import`, `CreateSplitKey`, and `JoinSplitKey` even before the ceremony +completes, breaking the chicken-and-egg problem. +to custodians. The number of shares is auto-determined by the server from the +`crypto_officer_users` count, and each share is auto-assigned to a different CO +candidate (dual-control enforcement). ```mermaid sequenceDiagram - actor Candidate as CO candidate + actor Candidate as CO candidate
(ceremony mode active) participant KMS + Note over Candidate,KMS: Phase 1 — Ceremony provisioning + Candidate->>KMS: Create(AES-256) → key_id - Candidate->>KMS: SetAttribute(key_id, ceremony=true) + Candidate->>KMS: SetAttribute(key_id, x-cosmian-crypto-officer-ceremony=true) + Note right of KMS: Marks key as ceremony split input
(prevents generic split from distributing shares) + Candidate->>KMS: CreateSplitKey(key_id) - KMS-->>Candidate: share_1, share_2, ..., share_n + Note right of KMS: Auto-determines share count
from crypto_officer_users.len()
Assigns share i → co_users[i % n]
Source key destroyed after split + + KMS-->>Candidate: [share_1_id, share_2_id, ..., share_n_id] + + Note over Candidate: Each share owned by a different CO candidate
Candidate owns exactly ONE share - Note over Candidate: Distribute shares out-of-band,
ask each CO to grant GET access + Note over Candidate: Ask each other CO to grant GET access
after distributing share IDs out-of-band ``` -### Phase 2: Activate Crypto Officer Role (JoinSplitKey) +!!! note "Source key is destroyed" + The server destroys the ceremony source key immediately after all shares are stored. + +### Phase 2 — Activate Crypto Officer Role (JoinSplitKey) The CO candidate assembles all $n$ share UIDs (after each other CO grants GET access to -their share), then activates via one of two mechanisms, both reachable from the CLI and -the Web UI: +their share), then calls `JoinSplitKey`. The server: + +1. Retrieves each share — the candidate must have `Get` on each. +2. Verifies all shares carry `x-cosmian-crypto-officer-ceremony`. +3. Verifies all shares originate from the same source key. +4. Verifies the share count equals the threshold. +5. Verifies the candidate is in `crypto_officer_users`. +6. Verifies that at least one share is owned by a **different** CO (dual-control — prevents + solo self-activation). The activating candidate may own one or more shares; what is + forbidden is that *all* shares belong to the activating candidate alone. +7. Reconstructs the secret via XOR, stores it as a managed object. +8. Persists a `crypto_officer_activations` record (activated-by, participants, SHA-256 hash). +9. The candidate is now an **active CryptoOfficer**. + +```mermaid +sequenceDiagram + actor CO as CO candidate (e.g. Alice) + actor CO2 as CO2 (e.g. Bob — owns share#1) + actor CO3 as CO3 (e.g. Carol — owns share#3) + participant KMS + + Note over CO,KMS: Phase 2 — Activate Crypto Officer Role -- **KMIP `JoinSplitKey`** (`ckms sym keys join-split-key`, or the Web UI's Join Split Key - page): stores the reconstructed key as an Active managed object in `objects`, owned by - the caller. -- **`POST /access/crypto_officer/ceremony/activate`** (`ckms access-rights crypto-officer - activate`, or the Web UI's Crypto Officer Role page): reconstructs the secret in RAM - only to verify its SHA-256 fingerprint, then zeroizes it. No key object is stored. + CO2->>KMS: GrantAccess(share_1_id → Alice, Get) + CO3->>KMS: GrantAccess(share_3_id → Alice, Get) -Both run the same checks first: + CO->>KMS: JoinSplitKey([share_1_id, share_2_id, share_3_id]) + Note right of KMS: • Verify x-cosmian-crypto-officer-ceremony on all shares
• Verify all shares from same source key
• Verify count = n
• Verify Alice ∈ crypto_officer_users
• Verify at least one share owned by a different CO
• XOR reconstruction → store reconstructed key
• Persist crypto_officer_activations row + KMS-->>CO: JoinSplitKeyResponse{uid: "key_id"} -1. Retrieves each share (the candidate must have `Get` on each) and validates them: same - ceremony tag, same source key, correct count, and the candidate listed in - `crypto_officer_users`. -2. Checks that at least one share belongs to a **different** CO: the activating candidate - may own shares, but not all of them. This is what prevents solo self-activation. + Note over CO,KMS: CryptoOfficer role is now ACTIVE + CO->>KMS: GET /access/crypto_officer/status → {ceremony_activated: true} +``` -Either way, the server persists the `crypto_officer_activations` record and the candidate -is now an **active CryptoOfficer**. +!!! info "JoinSplitKey = Activation" + `JoinSplitKey` with ceremony-tagged shares is the activation mechanism. The reconstructed + key is stored as a KMS object, and the `crypto_officer_activations` record is written + automatically. No separate activation endpoint call is needed from the UI. + The dedicated REST endpoint `POST /access/crypto_officer/ceremony/activate` is kept + for CLI backward compatibility only. -!!! warning "Key storage difference between the two mechanisms" - `JoinSplitKey` stores the reconstructed key as a usable KMS object; `ceremony/activate` - does not — the secret is RAM-only and zeroized immediately after the activation record - is written. Pick based on whether you need the reconstructed key as a managed object - afterward. +### Phase 3 — Active use + +While the ceremony is active, the CryptoOfficer can manage all keys in the KMS: ```mermaid sequenceDiagram - actor Alice as Alice (CO candidate) - actor Bob as Bob - actor Carol as Carol + actor CO as CryptoOfficer (active) + actor Bob as Bob (object owner) participant KMS - Bob->>KMS: GrantAccess(share_1 → Alice, Get) - Carol->>KMS: GrantAccess(share_3 → Alice, Get) - Alice->>KMS: JoinSplitKey([share_1, share_2, share_3]) - KMS-->>Alice: Activated, CryptoOfficer role is now ACTIVE + Note over CO,KMS: Phase 3 — CryptoOfficer in use + + Bob->>KMS: Create(AES-256) → bob_key_id + Note right of KMS: object owner = Bob + + CO->>KMS: Get(bob_key_id) + Note right of KMS: is_crypto_officer(CO) = true
→ ownership bypass granted
CRYPTO_OFFICER_ACCESS logged + KMS-->>CO: SymmetricKey (bob_key_id) + + CO->>KMS: Locate(any_attributes) + Note right of KMS: find_all() bypasses user filter
returns ALL objects in KMS + KMS-->>CO: [bob_key_id, ...] ``` -## Revoking +### Phase 4 — Revocation Any configured CO candidate may revoke the active CO's ceremony: -| Who calls | Outcome | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------- | -| **Active CO** (currently holds the ceremony) | Immediate self-revoke: 200 OK. | -| **Any other CO candidate** (in `crypto_officer_users`, not currently active) | Peer revocation: revokes the active CO's role immediately. | -| Any other user | 401 Unauthorized. | +| Who calls | Outcome | +|---|---| +| **Active CO** (currently holds the ceremony) | Immediate self-revoke — 200 OK. | +| **Any other CO candidate** (in `crypto_officer_users`, not currently active) | Peer revocation — revokes the active CO's role immediately. | +| Any other user | 401 Unauthorized. | -The reconstructed key is **NOT revoked**; only the `crypto_officer_activations` row +The reconstructed key is **NOT revoked** — only the `crypto_officer_activations` row is updated. The demoted CO retains their reconstructed key as an Operator. -### Emergency revocation (config path) +```mermaid +sequenceDiagram + actor Alice as Alice (active CO) + actor Bob as Bob (CO candidate, not active) + participant KMS + + Note over Alice,KMS: Scenario A — Active CO self-revoke + + Alice->>KMS: POST /access/crypto_officer/disable + Note right of KMS: is_crypto_officer(Alice) = true
UPDATE revoked_at = NOW()
Reconstructed key unchanged + KMS-->>Alice: 200 OK — "Ceremony revoked" + + Note over Alice,KMS: Role DORMANT — reconstructed key still owned by Alice + + Note over Bob,KMS: Scenario B — Peer revocation (compromise recovery) + + Bob->>KMS: POST /access/crypto_officer/disable + Note right of KMS: Bob ∈ crypto_officer_users
UPDATE revoked_at = NOW()
Alice's reconstructed key unchanged + KMS-->>Bob: 200 OK — "Ceremony revoked" + + Note over Bob,KMS: Alice demoted to Operator — Bob still not active +``` + +#### Emergency revocation (config path) When all CO candidates are unavailable: @@ -204,54 +300,57 @@ When all CO candidates are unavailable: --- -## Quick reference - -### Permission model - -```text -Request: operation OP by user U -│ -├─ crypto_officer_users not configured -│ └─ Standard owner/grant check (no role restrictions) -│ -└─ crypto_officer_users configured - │ - ├─ U not in crypto_officer_users - │ └─ role = Operator (fail-secure default) - │ - └─ U in crypto_officer_users - │ - ├─ require_ceremony = false - │ └─ role = CryptoOfficer: GRANTED - │ (lifecycle + key output + ownership bypass) - │ - └─ require_ceremony = true - │ - ├─ no active row in crypto_officer_activations - │ └─ role = Operator (dormant until ceremony completes) - │ - └─ active row in crypto_officer_activations - └─ role = CryptoOfficer: GRANTED - -Once a role is assigned (CryptoOfficer or Operator): -│ -└─ OP in the role's allowed operations? - │ - ├─ No → DENIED (Unauthorized) - │ - └─ Yes → handler-level ownership/grant check - │ - ├─ Denied → DENIED (Unauthorized) - └─ Granted → GRANTED +## Security properties + +| Property | Guarantee | +|---|---| +| **Information-theoretic secrecy** | $< n$ shares reveal zero bits about the secret | +| **Single-point-of-failure elimination** | No single custodian can activate the role alone | +| **Insider threat mitigation** | A CO candidate cannot escalate without all custodians cooperating (n ≥ 3 prevents dealer computing other shares) | +| **Dealer-colluder resistance** | With n ≥ 3, the key creator knows one share; deriving any other individual share is impossible without that custodian's cooperation | +| **Audit trail** | Every activation records: activator, participant list, SHA-256 key fingerprint, timestamp | +| **Self-revocability** | The active CO can revoke their own ceremony immediately in one call | +| **Peer revocability** | Any CO candidate can revoke the active CO — enables compromise recovery without server restart | +| **Reconstructed key independent** | Revoking the CO role does NOT destroy the reconstructed key — it remains accessible to its owner as an Operator | +| **Tag-based escalation prevention** | CO role is determined by `crypto_officer_activations` table only. The `x-cosmian-crypto-officer-ceremony` tag on KMS objects is used only as validation input, never for privilege checks. | +| **Dual-control enforcement** | Assembling user must not own any share — all shares must come from other CO candidates | +| **Replay prevention** | Re-activation requires re-running the full ceremony (JoinSplitKey with new ceremony-tagged shares) | +| **RAM-only reconstruction** | During JoinSplitKey, the XOR secret is reconstructed in process RAM only; zeroized after storing the reconstructed key (ADP-20) | +| **Ceremony key destruction** | The source key is automatically destroyed after all shares are stored, removing any direct reconstruction path | +| **HSM key exclusion** | Ownership bypass does not apply to HSM-backed keys (governed by HSM admin rules) | +| **Emergency recovery** | If all CO candidates unavailable: remove from `crypto_officer_users` and restart | + +--- + +## Permission model + +```mermaid +flowchart TD + A([Request: OP by user U]) --> B{crypto_officer_users
configured?} + B -- No --> C[Standard owner/grant check
no role restrictions] + B -- Yes --> CO{U in
crypto_officer_users?} + CO -- Yes --> COC{require_ceremony?} + COC -- No --> COA[CryptoOfficer — GRANTED
lifecycle + key output + ownership bypass] + COC -- Yes --> COD{crypto_officer_activations
has active row for U?} + COD -- No --> K[Assign Operator
role dormant] + COD -- Yes --> COA + CO -- No --> G[Assign Operator
fail-secure] + COA --> M{OP in
allowed_ops?} + G --> M + K --> M + M -- Yes --> N[Handler-level
ownership/grant check] + M -- No --> O[DENIED — Unauthorized] + N -- Granted --> P[GRANTED] + N -- Denied --> O ``` --- -### Configuration +## Configuration reference ```toml [roles] -# ── CryptoOfficer role: key lifecycle management + ownership bypass ───────── +# ── CryptoOfficer role — key lifecycle management + ownership bypass ───────── crypto_officer_users = ["key-mgr@example.com"] # Set to true to require a JoinSplitKey ceremony before the role becomes active. @@ -270,12 +369,12 @@ ceremony_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abc !!! note "Operator is the default" Users not listed in `crypto_officer_users` automatically receive Operator privileges. - There is no `operator_users` config key; the Operator role is the implicit + There is no `operator_users` config key — the Operator role is the implicit fail-secure default. --- -### CLI +## CLI quick reference ```bash # 1. Create ceremony key (as CO candidate, before ceremony) @@ -297,7 +396,7 @@ ckms sym keys create-split-key --key-id ceremony-key-2026 --ceremony # (run as each other CO candidate): ckms access-rights grant -i get -# 5. Activate: JoinSplitKey IS the activation (no separate step needed) +# 5. Activate — JoinSplitKey IS the activation (no separate step needed) ckms sym keys join-split-key # → CO role activated; reconstructed key stored @@ -308,7 +407,7 @@ ckms access-rights crypto-officer status ckms access-rights crypto-officer disable ``` -#### REST API equivalents +### REST API equivalents ```bash # Status @@ -331,40 +430,19 @@ curl -s -X POST https:///access/crypto_officer/disable --- -### Role store vs. key store - -**Important security boundary:** - -| Store | Written by | Purpose | -| ------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crypto_officer_activations` DB table | `JoinSplitKey` on ceremony shares, or `POST /access/crypto_officer/ceremony/activate` | **Sole source of truth** for CO role status. Sealed with AES-256-GCM under `ceremony_secret`. | -| `objects` DB table | Every `JoinSplitKey` call (ceremony and non-ceremony) | Stores the reconstructed key as a managed KMS object owned by the caller. For ceremony shares the key is stored **unconditionally** before the activation side-effect runs. | - -!!! info "Two ceremony completion paths" - - **`JoinSplitKey` KMIP operation**: stores the reconstructed key in `objects` **and** writes the CO activation record. Suitable when you need the reconstructed key as a usable KMS object. - - **`POST /access/crypto_officer/ceremony/activate`**: reconstructs the secret in RAM only (for hash verification), writes the CO activation record, and **does not store a key object**. - -The `x-cosmian-crypto-officer-ceremony` tag on shares identifies which shares belong to -a ceremony split. **It does NOT grant any privilege.** The server checks this tag only -during ceremony activation validation, never for privilege checks. This prevents an -attacker calling `Create(key)` + `SetAttribute(x-cosmian-crypto-officer-ceremony=true)` -from escalating to CO role. - ---- - -## Standards this design draws on +## References -This design is inspired by two publications: -[FIPS 140-3](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.140-3.pdf) (cryptographic -module role separation) and -[NIST SP 800-57 Part 2 Rev 1](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt2r1.pdf) -(split knowledge and dual control as organizational practices to document). Neither -standard mandates a specific implementation. +| # | Standard | Full title | Link | +|---|---|---|---| +| 1 | FIPS 140-3 | NIST FIPS PUB 140-3, *Security Requirements for Cryptographic Modules*, March 2019. | [PDF](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.140-3.pdf) | +| 2 | SP 800-57 Part 2 Rev 1 | NIST SP 800-57 Part 2 Rev 1, *Recommendation for Key Management: Part 2 — Best Practices for Key Management Organizations*, May 2019. | [PDF](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt2r1.pdf) | +| 3 | SP 800-152 | NIST SP 800-152, *A Profile for U.S. Federal Cryptographic Key Management Systems (CKMS)*. FR:6.118/6.119 (personnel compromise minimization and recovery). | [PDF](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-152.pdf) | +| 4 | ANSI INCITS 359-2004 | *Information Technology — Role Based Access Control*. Defines `DeassignUser(user, role)` as a mandatory RBAC administrative operation. | [Standard](https://webstore.ansi.org/standards/incits/ansiincits3592004) | --- ## Related pages -- [Authorization and access rights](../authorization.md) +- [Authorization and access rights](./index.md) - [Configuration file reference](../server_configuration_file.md) - [FIPS 140-3 compliance](../../certifications_and_compliance/fips.md) diff --git a/documentation/docs/configuration/authorization/mode1.md b/documentation/docs/configuration/authorization/mode1.md new file mode 100644 index 0000000000..2cbbaef65c --- /dev/null +++ b/documentation/docs/configuration/authorization/mode1.md @@ -0,0 +1,246 @@ +# Mode 1 — Native KMS Permissions + +When no OPA server is configured (`--opa-url` is unset), the KMS uses its built-in +permission system. This is the default mode. + +```toml +# kms.toml — no [opa] section needed +``` + +--- + +## How it works + +```mermaid +sequenceDiagram + actor Client + participant KMS as KMS Server + participant DB as KMS Database + + Client->>KMS: KMIP request + credentials + KMS->>KMS: Authenticate (JWT / mTLS / API token) + KMS->>DB: retrieve_objects(uid_or_tags) + DB-->>KMS: ObjectWithMetadata (owner, state, grants) + KMS->>KMS: Evaluate permission (see flowchart below) + alt Granted + KMS->>DB: Execute KMIP operation + KMS-->>Client: KMIP response (success) + else Denied + KMS-->>Client: Error: Object Not Found + end +``` + +--- + +## Permission evaluation flowchart + +```mermaid +flowchart TD + Start([Request for operation OP on object]) --> Owner{Is user the owner?} + Owner -->|Yes| Allow([Granted]) + Owner -->|No| HSM{Is this an HSM key?} + HSM -->|Yes| HSMAdmin{Is user HSM admin?} + HSMAdmin -->|Yes| Allow + HSMAdmin -->|No| HSMGrant{User has OP granted?} + HSMGrant -->|Yes| Allow + HSMGrant -->|No| HSMEquiv{OP=Get and has Export?
or OP=Export and has Get?} + HSMEquiv -->|Yes| Allow + HSMEquiv -->|No| Deny([Denied]) + HSM -->|No| Explicit{User has OP granted?} + Explicit -->|Yes| Allow + Explicit -->|No| GetWild{User has Get granted?} + GetWild -->|Yes| Allow + GetWild -->|No| Deny +``` + +--- + +## Core principles + +### Ownership + +Every cryptographic object has an assigned owner. Ownership is established when an +object is created via `Create`, `CreateKeyPair`, or `Import`. The owner can perform +**all** KMIP operations on their objects. + +### Access rights delegation + +Owners can grant access rights, allowing other users to perform specific KMIP +operations on an object. The owner retains the authority to withdraw these access +rights at any time. + +--- + +## Delegable KMIP operations + +| Operation | Description | +| ------------------- | --------------------------------------------------------------- | +| `create` | Create new cryptographic objects (symmetric keys, key pairs, …) | +| `certify` | Issue or renew X.509 certificates | +| `decrypt` | Decrypt ciphertext using a managed key | +| `derive_key` | Derive a new key from an existing key | +| `destroy` | Permanently destroy an object | +| `encrypt` | Encrypt plaintext using a managed key | +| `export` | Export an object (key material + metadata) from the KMS | +| `get` | Retrieve an object — **this is a super-privilege** (see below) | +| `get_attributes` | Read the KMIP attributes of an object | +| `hash` | Compute a cryptographic hash | +| `import` | Import an external object into the KMS | +| `locate` | Search for objects matching given attributes | +| `mac` | Compute a Message Authentication Code | +| `revoke` | Revoke (deactivate) an object | +| `rekey` | Re-key an existing symmetric key | +| `sign` | Generate a digital signature | +| `signature_verify` | Verify a digital signature | +| `validate` | Validate a certificate chain | +| `set_attribute` | Set (replace) an attribute on an object | +| `modify_attribute` | Modify an existing attribute on an object | +| `add_attribute` | Add a new attribute value to an object | +| `delete_attribute` | Remove an attribute from an object | + +Multiple operations can be granted or revoked in a single call: + +```bash +# Grant encrypt and decrypt to user "alice" +ckms access-rights grant alice -i encrypt decrypt + +# Revoke the get privilege from user "bob" +ckms access-rights revoke bob -i get +``` + +--- + +## The `Get` super-privilege + +The `Get` operation acts as a **super-privilege that implies every other object-level +operation** (except lifecycle operations `revoke` and `destroy`). + +The evaluation order: + +1. **Owner check** — owner always has full access. +2. **Explicit permission** — user has been granted the specific operation. +3. **`Get` fallback** — user holds `Get` → access granted for any non-lifecycle operation. + +| Granted permissions | Can `encrypt`? | Can `export`? | Can `destroy`? | +| -------------------- | :------------: | :-----------: | :------------: | +| `encrypt` | Yes | No | No | +| `get` | Yes | Yes | No | +| `encrypt`, `destroy` | Yes | No | Yes | +| `get`, `destroy` | Yes | Yes | Yes | + +!!! warning "Security implication" + Grant `get` with care. If you only need a user to encrypt data, grant `encrypt` — not `get`. + +!!! note + `destroy` and `revoke` are **never** implied by `get`. They require explicit grants. + +--- + +## Special handling of the `Create` permission + +The `Create` operation controls whether a user can create *new* objects. It is stored +against the wildcard object identifier `*`. + +- When granting or revoking `create`, no object UID is required. +- `Create` can be combined with object-level operations in the same request. + +--- + +## Privileged users + +By default all users can create or import objects. When `privileged_users` is +configured in `kms.toml`: + +- Only privileged users can create/import objects. +- Privileged users can grant/revoke `create` to regular users. +- Regular users cannot create unless explicitly granted by a privileged user. +- Privileged users cannot revoke creation from other privileged users. + +### Operations gated by the privileged-user restriction + +| Operation | Reason | +| ---------------- | ------------------------------------------------------- | +| `Create` | Creates a new symmetric key or secret data object | +| `CreateKeyPair` | Creates a new asymmetric key pair | +| `Import` | Imports an external object into the KMS | +| `Register` | Registers an externally-generated object | +| `Certify` | May create a new key pair when issuing a certificate | +| `ReKey` | Creates a new replacement symmetric key | +| `ReKeyKeyPair` | Creates a new replacement asymmetric key pair | + +--- + +## The wildcard user `*` + +!!! important "The Wildcard User: *" + Granting a permission to user `*` makes it effective for **all** authenticated users. + Per-user grants are merged with wildcard grants during evaluation. + +--- + +## HSM keys + +Keys stored in an HSM follow a stricter permission model: + +| Aspect | KMS keys | HSM keys | +| ----------------------------- | ------------------------------------- | ----------------------------------------------- | +| Key material stored in | KMS database (encrypted) | HSM hardware | +| `Get` is a super-privilege | Yes | **No** — each operation must be granted | +| `Get` ↔ `Export` equivalence | No | **Yes** — holding either grants both | +| `Destroy` / `Revoke` delegable | Yes | **No** — admin-only | +| `Create` | Any user (or privileged) | HSM admin only | + +See the [HSM operations](../../hsm_support/hsm_operations.md) page for details. + +--- + +## Access management endpoints + +| Method | Endpoint | Description | +| ------ | -------------------------- | --------------------------------------------------------- | +| POST | `/access/grant` | Grant operations on an object to a user | +| POST | `/access/revoke` | Revoke operations on an object from a user | +| GET | `/access/list/{object_id}` | List all access rights granted on an object (owner only) | +| GET | `/access/owned` | List all objects owned by the authenticated user | +| GET | `/access/obtained` | List all access rights obtained by the authenticated user | +| GET | `/access/create` | Check whether the authenticated user can create objects | +| GET | `/access/privileged` | Check whether the authenticated user is privileged | + +--- + +## Authorization rules summary + +| Scenario | Access granted? | +| ------------------------------------------------ | :-------------: | +| User is the object owner | Always | +| User has the exact requested operation granted | Yes | +| User has `Get` granted (non-lifecycle operation) | Yes | +| User has no matching permission | Denied | +| User tries to grant/revoke own permissions | Denied | +| Non-owner tries to grant permissions | Denied | + +--- + +## Typical workflow + +### Step 1 — Create the key (as admin/owner) + +```bash +ckms sym keys create --algorithm aes --number-of-bits 256 --tag user-alice-key +``` + +### Step 2 — Grant limited permissions + +```bash +ckms access-rights grant alice@example.com -i encrypt decrypt +``` + +### Step 3 — Alice uses the key + +Alice authenticates and calls encrypt/decrypt referencing the key UID. + +### Step 4 — Revoke access + +```bash +ckms access-rights revoke alice@example.com -i encrypt decrypt +``` diff --git a/documentation/docs/configuration/authorization/mode2.md b/documentation/docs/configuration/authorization/mode2.md new file mode 100644 index 0000000000..f392f97d9b --- /dev/null +++ b/documentation/docs/configuration/authorization/mode2.md @@ -0,0 +1,170 @@ +# Mode 2 — Exclusive OPA (RBAC) + +When the OPA server is configured with `opa_mode = "exclusive"`, OPA is the **sole +authorization decision maker**. The native KMS permission system (ownership, grants) +is completely bypassed. + +```toml +# kms.toml — Mode 2 +[opa] +opa_url = "http://localhost:8181" +opa_mode = "exclusive" +``` + +| Environment variable | Example | +| -------------------- | ------- | +| `KMS_OPA_URL` | `http://localhost:8181` | +| `KMS_OPA_MODE` | `exclusive` | + +--- + +## Prerequisites + +- **JWT authentication required** — all users must authenticate via JWT issued by the + Eviden Authentication Server. The JWT must contain the `roles` and `as_domain` claims. +- **Non-JWT auth is fail-closed** — mTLS and API token users receive `roles: []` in the + OPA input, causing all role-based rules to evaluate to `false`. +- **Native KMS grants are ignored** — even if a user has explicit grants in the KMS + database, they are not consulted. + +--- + +## Sequence diagram + +```mermaid +sequenceDiagram + actor Client + participant AuthSrv as Authentication Server + participant KMS as KMS Server + participant OPA as OPA Server + participant DB as KMS Database + + Client->>AuthSrv: POST /login (username + password + TOTP) + AuthSrv-->>Client: JWT (sub, roles, as_domain) + + Client->>KMS: KMIP request + Bearer JWT + KMS->>KMS: Verify JWT signature via JWKS + KMS->>KMS: Extract sub, roles, as_domain + + KMS->>DB: retrieve_objects(uid_or_tags) + DB-->>KMS: ObjectWithMetadata (owner, domain) + + KMS->>OPA: POST /v1/data/kms/allow + Note right of KMS: {input: user, roles,
user_domain, operation,
object_uid, object_domain,
is_owner} + OPA->>OPA: Evaluate kms.rego + OPA-->>KMS: {result: true/false} + + alt OPA allows + KMS->>DB: Execute KMIP operation + KMS-->>Client: KMIP response (success) + else OPA denies or unreachable + KMS-->>Client: Error: Access Denied + end +``` + +--- + +## OPA decision flowchart + +The Rego policy evaluates rules top-to-bottom. The first matching rule grants access: + +```mermaid +flowchart TD + Start([OPA receives input]) --> Owner{is_owner?} + Owner -->|Yes| Allow([Allow]) + Owner -->|No| SA{SuperAdmin role?} + SA -->|Yes| Allow + SA -->|No| DA{DomainAdmin role?} + DA -->|Yes, same domain| Allow + DA -->|No| CO{CryptoOfficer role?} + CO -->|Yes, same domain + valid op| Allow + CO -->|No| AU{Auditor role?} + AU -->|Yes, same domain + audit op| Allow + AU -->|No| US{User role?} + US -->|Yes, same domain + user op| Allow + US -->|No| Deny([Deny]) +``` + +--- + +## Fail-closed behaviour + +```mermaid +flowchart LR + KMS([KMS sends query]) --> OPA{OPA reachable?} + OPA -->|Yes, result=true| Allow([Allow]) + OPA -->|Yes, result=false| Deny([Deny]) + OPA -->|Timeout / error / non-2xx| Deny + OPA -->|Response parse failure| Deny +``` + +If OPA is configured but unreachable, **all requests are denied**. This is +intentional: a crashed sidecar does not degrade the KMS to open access. + +--- + +## What is NOT evaluated in Mode 2 + +| Native KMS concept | Evaluated? | Reason | +| ------------------ | :--------: | ------ | +| Object ownership grants | No | OPA handles `is_owner` directly | +| Per-user operation grants | No | Replaced by role-based rules | +| `Get` super-privilege | No | OPA does not implement this shortcut | +| Privileged users (creation rights) | No | OPA controls who can `Create` | +| HSM admin bypass | No | OPA handles all HSM key decisions | +| Ceremony super-admin | No | Native KMS gate is not consulted | + +--- + +## Deploying the OPA sidecar + +Minimal start: + +```bash +opa run --server --addr :8181 kms.rego +``` + +Production (hot-reloadable via bundles): + +```bash +opa run --server --addr :8181 \ + --set bundles.kms.service=policy-service \ + --set bundles.kms.resource=kms/bundle.tar.gz \ + --set services.policy-service.url=https://policy.example.com +``` + +The policy can be updated at runtime without restarting KMS or OPA. + +--- + +## Debugging denied requests + +Query the debug endpoint to see which rules matched: + +```bash +curl -s -X POST http://localhost:8181/v1/data/kms/reason \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "operation": "destroy", + "object_uid": "key-123", + "object_domain": "acme.com", + "is_owner": false + } + }' +``` + +Response: `{"result": ["crypto_officer"]}` — the `CryptoOfficer` role allows `destroy`. + +If the response contains `"denied"`, no rule matched. + +--- + +## See also + +- [Authorization overview](index.md) — role model, JWT claims, OPA input document +- [Mode 3 — Enforcing](mode3.md) — dual-gate mode (OPA + native KMS) +- Rego policy source: `test_data/opa/kms.rego` diff --git a/documentation/docs/configuration/authorization/mode3.md b/documentation/docs/configuration/authorization/mode3.md new file mode 100644 index 0000000000..8c61ea8d02 --- /dev/null +++ b/documentation/docs/configuration/authorization/mode3.md @@ -0,0 +1,194 @@ +# Mode 3 — Enforcing (OPA + Native KMS) + +When the OPA server is configured with `opa_mode = "enforcing"`, **both** authorization +systems must allow the request. OPA acts as the first gate; if it denies, the request +is rejected immediately. If OPA allows, the native KMS permission system runs as a +second gate with **veto power**. + +```toml +# kms.toml — Mode 3 +[opa] +opa_url = "http://localhost:8181" +opa_mode = "enforcing" +``` + +| Environment variable | Example | +| -------------------- | ------- | +| `KMS_OPA_URL` | `http://localhost:8181` | +| `KMS_OPA_MODE` | `enforcing` | + +→ **[Full setup guide: OPA + Authentication Verifier](opa-authverifier-setup.md)** + +--- + +## Architecture overview + +```mermaid +flowchart LR + subgraph Client + U["User / Application"] + end + + subgraph AuthServer["Authentication Verifier"] + IDP["Cosmian Auth Verifier\n(JWT issuer — roles, as_rid)"] + end + + subgraph PolicyServer["Policy Plane"] + OPA["OPA Server\nPOST /v1/data/kms/allow"] + Rego["kms.rego\n(role definitions)"] + OPA -.- Rego + end + + subgraph KMSServer["KMS Server"] + KMS["Cosmian KMS\n:9998"] + DB[("SQLite / PostgreSQL\n/ Redis-Findex")] + KMS -.- DB + end + + U -->|"1 — login"| IDP + IDP -->|"2 — JWT (sub, roles, as_rid)"| U + U -->|"3 — KMIP request + ******"| KMS + KMS -->|"4 — verify JWT (JWKS)"| IDP + KMS -->|"5 — POST /v1/data/kms/allow"| OPA + OPA -->|"6 — allow / deny"| KMS + KMS -->|"7 — KMIP response"| U +``` + +--- + +## When to use Mode 3 + +- **Migration path** — you have an existing Mode 1 deployment with fine-grained grants + and want to layer RBAC guardrails without discarding them. +- **Defence in depth** — role-based rules prevent broad misuse; per-object grants + enforce least-privilege on individual keys. +- **Compliance** — some standards require both role-based and object-level access + control to be active simultaneously. + +--- + +## Sequence diagram + +```mermaid +sequenceDiagram + actor Client + participant AuthSrv as Authentication Server + participant KMS as KMS Server + participant OPA as OPA Server + participant DB as KMS Database + + Client->>AuthSrv: POST /login (username + password + TOTP) + AuthSrv-->>Client: JWT (sub, roles, as_domain) + + Client->>KMS: KMIP request + Bearer JWT + KMS->>KMS: Verify JWT signature via JWKS + KMS->>KMS: Extract sub, roles, as_domain + + KMS->>DB: retrieve_objects(uid_or_tags) + DB-->>KMS: ObjectWithMetadata (owner, domain, grants) + + rect rgb(255, 240, 240) + Note over KMS,OPA: Gate 1 — OPA + KMS->>OPA: POST /v1/data/kms/allow + OPA->>OPA: Evaluate kms.rego + OPA-->>KMS: {result: true/false} + end + + alt OPA denies or unreachable + KMS-->>Client: Error: Access Denied + else OPA allows + rect rgb(240, 255, 240) + Note over KMS,DB: Gate 2 — Native KMS + KMS->>KMS: Check owner / grants / HSM admin + end + alt Native KMS allows + KMS->>DB: Execute KMIP operation + KMS-->>Client: KMIP response (success) + else Native KMS denies + KMS-->>Client: Error: Access Denied + end + end +``` + +--- + +## Dual-gate decision flowchart + +```mermaid +flowchart TD + Start([KMIP request arrives]) --> OPA{Gate 1: OPA allows?} + OPA -->|No / unreachable| Deny([Denied]) + OPA -->|Yes| KMS{Gate 2: Native KMS allows?} + KMS -->|No| Deny + KMS -->|Yes| Allow([Granted]) +``` + +### Gate 1 detail — OPA evaluation + +Same as [Mode 2](mode2.md): role hierarchy, domain scoping, owner override. + +### Gate 2 detail — Native KMS evaluation + +Same as [Mode 1](mode1.md): owner → HSM admin → explicit grant → Get wildcard. + +The ceremony super-admin (Shamir split-key) operates exclusively within Gate 2 and is +invisible to OPA. + +--- + +## Interaction scenarios + +| OPA decision | Native KMS decision | Final result | Explanation | +| :----------: | :-----------------: | :----------: | ----------- | +| Allow | Allow (owner) | **Granted** | Both gates pass | +| Allow | Allow (grant) | **Granted** | Role allows + explicit grant exists | +| Allow | Deny | **Denied** | Role allows but no object-level permission | +| Deny | _(not evaluated)_ | **Denied** | Short-circuit: OPA veto | +| Unreachable | _(not evaluated)_ | **Denied** | Fail-closed: OPA down | +| Allow | Allow (HSM admin) | **Granted** | HSM admin passes Gate 2 | +| Allow | Allow (ceremony SA) | **Granted** | Ceremony super-admin passes Gate 2 | + +--- + +## Practical example + +Alice has `CryptoOfficer` role in domain `acme.com` and was granted `encrypt` on key +`key-123` (also in domain `acme.com`). + +```text +Gate 1 (OPA): CryptoOfficer + same domain + "encrypt" in crypto_officer_ops → Allow ✓ +Gate 2 (KMS): Alice has explicit "encrypt" grant on key-123 → Allow ✓ +Final: Granted +``` + +Now Alice tries `destroy` on `key-123`: + +```text +Gate 1 (OPA): CryptoOfficer + same domain + "destroy" in crypto_officer_ops → Allow ✓ +Gate 2 (KMS): Alice has no "destroy" grant and is not owner → Deny ✗ +Final: Denied +``` + +OPA role allows it, but the native KMS gate vetoes because no explicit grant exists. + +--- + +## Comparison with Mode 2 + +| Aspect | Mode 2 (Exclusive) | Mode 3 (Enforcing) | +| ------ | :----------------: | :----------------: | +| OPA consulted | Yes | Yes | +| Native KMS consulted | No | Yes (second gate) | +| OPA deny = final deny | Yes | Yes | +| Native KMS can veto | N/A | **Yes** | +| Ceremony super-admin | Suspended | Active (in Gate 2) | +| Per-object grants used | No | **Yes** | +| HSM admin bypass | No | **Yes** (in Gate 2) | + +--- + +## See also + +- [Authorization overview](index.md) — role model, JWT claims, OPA input document +- [Mode 1 — Native KMS permissions](mode1.md) — details on Gate 2 logic +- [Mode 2 — Exclusive OPA](mode2.md) — details on Gate 1 logic diff --git a/documentation/docs/configuration/authorization/opa-authverifier-setup.md b/documentation/docs/configuration/authorization/opa-authverifier-setup.md new file mode 100644 index 0000000000..cb7bc7e5ce --- /dev/null +++ b/documentation/docs/configuration/authorization/opa-authverifier-setup.md @@ -0,0 +1,323 @@ +# OPA + Authentication Verifier Setup + +Step-by-step configuration guide for running Mode 2 (exclusive OPA) or +Mode 3 (enforcing OPA + native KMS) with an OPA sidecar and the Cosmian +Authentication Verifier as the JWT issuer. + +--- + +## Step 1 — Authentication Verifier: issuing JWTs with role claims + +The KMS reads three JWT claims for RBAC: + +| Claim | RFC | Content | Example | +|---|---|---|---| +| `sub` | RFC 7519 §4.1.2 | User identity (forwarded to OPA as `input.user`) | `"alice@acme.com"` | +| `roles` | RFC 9068 §2.2.3.1 | Array of role strings | `["CryptoOfficer"]` | +| `as_rid` | private (RFC 7519 §4.3) | Realm ID = tenant domain (forwarded as `input.user_domain`) | `"acme.com"` | + +> The KMS accepts both **`as_rid`** (Cosmian Authentication Verifier) and **`as_domain`** +> (legacy alias) for the domain claim. Third-party IdPs should map their tenant field to +> `as_rid`. + +### Cosmian Authentication Verifier (recommended) + +The Cosmian Authentication Verifier is the reference IdP for this feature. It supports +realms (= domains) and per-user role assignment natively, and emits the `roles` and +`as_rid` claims required by the KMS OPA policy. + +→ **[Authentication Verifier installation and configuration](https://docs.cosmian.com/authentication_verifier/installation.html)** + +Once the Authentication Verifier is running, provision a realm and users: + +```bash +CA=/path/to/auth-verifier/certs/auth.ca.pem + +# 1 — Login as super-admin (stores session cookie) +curl -s --cacert $CA -c /tmp/auth-admin.txt \ + -X POST "https://localhost:8443/login?realm=_" \ + -u "admin:change_me" \ + -H "Content-Type: application/json" \ + -d '{"public_key_pem":null,"totp_code":null}' + +# 2 — Create realm "acme.com" +curl -s --cacert $CA -b /tmp/auth-admin.txt \ + -X POST "https://localhost:8443/admins/realms" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "acme.com", + "auth_params": { + "username_password_params": {"allow_expired_passwords": false} + }, + "session_max_age_seconds": 3600, + "session_max_stale_age_seconds": 7200 + }' + +# 3 — Create a user with role CryptoOfficer +# Passwords must be hashed: Argon2id(password, salt=base64(SHA-256(username))) +HASH=$(python3 - << 'EOF' +import hashlib, base64 +from argon2 import PasswordHasher +username, password = "alice", "alice-pass" +salt = base64.b64encode(hashlib.sha256(username.encode()).digest()).rstrip(b"=") +ph = PasswordHasher(time_cost=3, memory_cost=4096, parallelism=1, hash_len=32) +print(ph.hash(password, salt=base64.b64decode(salt + b"=="))) +EOF +) + +curl -s --cacert $CA -b /tmp/auth-admin.txt \ + -X POST "https://localhost:8443/realms/acme.com/userpass" \ + -H "Content-Type: application/json" \ + -d "{ + \"realm\": \"acme.com\", + \"username\": \"alice\", + \"password\": \"$HASH\", + \"change_password\": false, + \"roles\": [\"CryptoOfficer\"], + \"domain\": \"acme.com\" + }" +``` + +#### Obtain a JWT + +```bash +JWT=$(curl -s --cacert $CA -D - \ + -X POST "https://localhost:8443/login?realm=acme.com" \ + -u "alice:alice-pass" \ + -H "Content-Type: application/json" \ + -d '{"public_key_pem":null,"totp_code":null}' \ + | grep -i "set-cookie: _ea_=" \ + | sed 's/.*_ea_=\([^;]*\).*/\1/' | tr -d '\r') + +# Inspect claims (optional) +echo $JWT | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool +``` + +#### Configure KMS to trust the Authentication Verifier + +```toml +# kms.toml +[idp_auth] +# Format: "issuer,jwks_uri" +jwt_auth_provider = ["cosmian-auth-test,https://localhost:8443/public/jwks"] + +[opa] +opa_url = "http://localhost:8181" +opa_mode = "enforcing" +``` + +| Endpoint | Method | Description | +|---|---|---| +| `GET /public/jwks` | — | JWKS endpoint (KMS fetches this to validate JWTs) | +| `GET /public/roles` | — | List of configured role names | +| `POST /admins/realms` | JSON | Create a realm | +| `POST /realms//userpass` | JSON | Create a user with roles + domain | +| `DELETE /realms//userpass/` | — | Delete a user | + +--- + +## Step 2 — OPA server: deploy and load the Rego policy + +### Docker Compose (recommended for production) + +```yaml +# docker-compose.yml +services: + opa: + image: openpolicyagent/opa:edge-static-debug + ports: + - "8181:8181" + volumes: + - ./test_data/opa/kms.rego:/policies/kms.rego:ro + command: + - run + - --server + - --log-level=info + - --addr=0.0.0.0:8181 + - /policies/kms.rego +``` + +```bash +docker compose up -d opa +``` + +### Standalone Docker + +```bash +docker run -d --name opa \ + -p 8181:8181 \ + -v "$(pwd)/test_data/opa/kms.rego:/policies/kms.rego:ro" \ + openpolicyagent/opa:edge-static-debug \ + run --server --log-level=info --addr=0.0.0.0:8181 /policies/kms.rego +``` + +### Verify OPA is ready + +```bash +curl http://localhost:8181/health + +# Test a CryptoOfficer create request — expect {"result":true} +curl -s -X POST http://localhost:8181/v1/data/kms/allow \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "operation": "create", + "object_uid": "*", + "object_domain": "acme.com", + "is_owner": false + } + }' +``` + +--- + +## Step 3 — KMS server: wire JWT auth and OPA + +### kms.toml + +```toml +[http] +hostname = "0.0.0.0" +port = 9998 + +[db] +database_type = "sqlite" +sqlite_path = "/var/lib/kms/data" + +[idp_auth] +jwt_auth_provider = ["cosmian-auth-test,https://localhost:8443/public/jwks"] + +[opa] +opa_url = "http://localhost:8181" +opa_mode = "enforcing" +``` + +### Environment variables + +| Variable | Example | Description | +|---|---|---| +| `KMS_JWT_AUTH_PROVIDER` | `https://auth.acme.com` | IdP issuer (+ optional JWKS URI and audiences) | +| `KMS_OPA_URL` | `http://localhost:8181` | OPA base URL | +| `KMS_OPA_MODE` | `enforcing` | `exclusive` or `enforcing` | + +### Starting the KMS server + +```bash +RUST_LOG="cosmian_kms_server=debug" \ +cosmian_kms \ + --database-type sqlite \ + --sqlite-path /tmp/kms-data \ + --jwt-auth-provider "cosmian-auth-test,https://auth.acme.com/public/jwks" \ + --opa-url http://localhost:8181 \ + --opa-mode enforcing +``` + +--- + +## Step 4 — Obtain a JWT and call the KMS + +### With the Cosmian Authentication Verifier + +```bash +CA=/path/to/auth-verifier/certs/auth.ca.pem + +JWT=$(curl -s --cacert $CA -D - \ + -X POST "https://auth.acme.com/login?realm=acme.com" \ + -u "alice:alice-pass" \ + -H "Content-Type: application/json" \ + -d '{"public_key_pem":null,"totp_code":null}' \ + | grep -i "set-cookie: _ea_=" \ + | sed 's/.*_ea_=\([^;]*\).*/\1/' | tr -d '\r') + +cat > /tmp/ckms-alice.toml << EOF +[http_config] +server_url = "http://kms.acme.com:9998" +access_token = "${JWT}" +EOF + +ckms -c /tmp/ckms-alice.toml sym keys create -t test-key +``` + +### Via PKCE / OAuth2 browser flow + +Standard OIDC providers that support OAuth2 PKCE can authenticate via a browser flow. +Configure `ckms.toml` with the provider's `authorize_url` and `token_url`, then +use `ckms login`: + +```toml +# ckms.toml +[http_config] +server_url = "http://kms.acme.com:9998" + +[http_config.oauth2_conf] +client_id = "ckms-client" +client_secret = "" +authorize_url = "https://auth.acme.com/oauth2/authorize" +token_url = "https://auth.acme.com/oauth2/token" +scopes = ["openid", "email"] +``` + +```bash +ckms -c ckms.toml login # opens browser → saves token +ckms -c ckms.toml sym keys create +ckms -c ckms.toml logout +``` + +--- + +## Debugging access denials + +### Query the OPA reason endpoint + +```bash +curl -s -X POST http://localhost:8181/v1/data/kms/reasons \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user": "alice@acme.com", + "user_domain": "acme.com", + "roles": ["CryptoOfficer"], + "operation": "destroy", + "object_uid": "key-123", + "object_domain": "acme.com", + "is_owner": false + } + }' +# {"result":["crypto_officer"]} → allowed +# {"result":["denied"]} → no rule matched +``` + +### Enable trace logging on the KMS + +```bash +RUST_LOG="cosmian_kms_server=trace" cosmian_kms ... +``` + +Look for `ensure_auth` (JWT parsing), `retrieve_object_utils` (OPA input), and +`core::opa::client` (HTTP round-trip timing). + +--- + +## Common problems + +| Symptom | Likely cause | Fix | +|---|---|---| +| `401 Unauthorized` | JWT missing or signature invalid | Check `--jwt-auth-provider` issuer and JWKS URI | +| `403` — OPA deny | Role not in JWT or cross-domain | Check `roles` claim; verify `as_rid` matches `object_domain` | +| `403` — OPA unreachable | OPA not running or wrong port | Check `KMS_OPA_URL`; run `curl http://localhost:8181/health` | +| `403` — Native KMS deny | Mode 3: no DB grant | Add grant with `ckms access-rights grant`, or switch to Mode 2 | +| Empty `roles: []` in OPA | mTLS or API-token auth | Roles only come from JWT | +| `roles` not in JWT | Authentication Verifier not configured | Check that the realm emits `roles` and `as_rid` claims — see the [Authentication Verifier docs](https://docs.cosmian.com/authentication_verifier/installation.html) | + +--- + +## See also + +- [Authorization overview](index.md) +- [Mode 3 — Enforcing (OPA + KMS)](mode3.md) +- [Authentication methods](../authentication.md) +- [Authentication Verifier documentation](https://docs.cosmian.com/authentication_verifier/installation.html) +- Rego policy source: `test_data/opa/kms.rego` diff --git a/documentation/docs/configuration/database/redis.md b/documentation/docs/configuration/database/redis.md index 8c1917b89a..cf90f40960 100644 --- a/documentation/docs/configuration/database/redis.md +++ b/documentation/docs/configuration/database/redis.md @@ -6,36 +6,6 @@ Redis-with-Findex combines application-level encryption with encrypted, searchab !!! warning "Non-FIPS only" Redis-with-Findex is gated behind the `non-fips` feature and is **not available in FIPS mode**. -## Configuration - -Redis-with-Findex requires the database URL and a master password: - -=== "kms.toml" - - ```toml - [db] - database_type = "redis-findex" - database_url = "redis://localhost:6379" - redis_master_password = "password" - redis_findex_label = "label" - ``` - -=== "Command line arguments" - - ```sh - --database-type=redis-findex \ - --database-url=redis://localhost:6379 \ - --redis-master-password=password \ - --redis-findex-label=label - ``` - -The corresponding environment variables are `KMS_DATABASE_TYPE`, `KMS_DATABASE_URL` (also `KMS_REDIS_URL`), `KMS_REDIS_MASTER_PASSWORD`, and `KMS_REDIS_FINDEX_LABEL`. - -For the full database configuration reference, including TLS, clearing, and migration, see [Databases](./configuration.md). - -!!! note "Clearing the database" - When `clear_database` is set, the KMS issues a `FLUSHDB` to Redis on startup, deleting all keys in the selected Redis database. - ## What it is With Redis-with-Findex, the KMS server encrypts all data before sending it to Redis: @@ -78,15 +48,37 @@ Instead it stores: | Database metadata | Internal keys holding the database state (`ready`/`upgrading`) and version | | Ceremony records | Encrypted records under key names obfuscated with the master key | -## Migration +## Configuration + +Redis-with-Findex requires the database URL and a master password: -**Version boundary**: Redis-with-Findex databases created with KMS **5.12.0 or later** carry a `ready` state marker and a version key in Redis, and start cleanly with the current KMS (5.26). -Databases created with KMS **earlier than 5.12.0** do not have these markers. -The KMS refuses to start against a marker-less database and prints an error asking you to export and re-import; there is no in-place upgrade path for those databases. +=== "kms.toml" + + ```toml + [db] + database_type = "redis-findex" + database_url = "redis://localhost:6379" + redis_master_password = "password" + redis_findex_label = "label" + ``` + +=== "Command line arguments" -**Supported upgrade paths**: + ```sh + --database-type=redis-findex \ + --database-url=redis://localhost:6379 \ + --redis-master-****** \ + --redis-findex-label=label + ``` + +The corresponding environment variables are `KMS_DATABASE_TYPE`, `KMS_DATABASE_URL` (also `KMS_REDIS_URL`), `KMS_REDIS_MASTER_PASSWORD`, and `KMS_REDIS_FINDEX_LABEL`. + +For the full database configuration reference, including TLS, clearing, and migration, see [Databases](./configuration.md). + +!!! note "Clearing the database" + When `clear_database` is set, the KMS issues a `FLUSHDB` to Redis on startup, deleting all keys in the selected Redis database. + +## Migration -| Source version | Path to 5.26 | -| -------------- | ------------ | -| ≥ 5.12 | Upgrade directly; no data migration needed. | -| < 5.12 | Export all objects from the old KMS, start a fresh 5.26 instance, re-import. | +Redis-with-Findex databases created by older KMS versions carry their version and state markers in Redis. +Support for migrating **legacy** Redis/Findex databases has been removed: if a database is detected without a `ready` state and a version marker, the KMS refuses to start and asks you to export the data from the legacy KMS and re-import it into the current version. diff --git a/documentation/docs/configuration/log-reference.md b/documentation/docs/configuration/log-reference.md index 4885c1a217..48aba4c8e7 100644 --- a/documentation/docs/configuration/log-reference.md +++ b/documentation/docs/configuration/log-reference.md @@ -57,7 +57,6 @@ Crate path: `crate/server` | `warn` | `SigV4 failure: {signature_error}` | `src/routes/aws_xks/sigv4_middleware.rs` | `signature_error`: SigV4 signature validation error | - | | `warn` | `Socket server: connection failed: {e}` | `src/socket_server.rs` | `e`: caught error | - | | `warn` | `UI folder invalid or Linux default detected, falling back to: {fallback:#?}` | `src/config/params/server_params.rs` | `fallback`: fallback UI folder path | - | -| `warn` | `{:?} {} 401 unauthorized, no email in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | | `warn` | `{:?} {} 401 unauthorized: bad JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | | `warn` | `{error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | - | | `warn` | `{status_code} - {message}` | `src/routes/mod.rs` | `status_code`: HTTP status code
`message`: human-readable message text | - | @@ -198,7 +197,6 @@ Crate path: `crate/server` | `debug` | `Imported object with uid: {}` | `src/core/operations/import.rs` | - | - | | `debug` | `Importing leaf certificate with attributes: {}` | `src/core/operations/import.rs` | - | - | | `debug` | `Importing PKCS12: private_key_id={:?}, leaf_certificate_id={:?}, chain={:?}` | `src/core/operations/import.rs` | - | - | -| `debug` | `JWT Access granted to {email}!` | `src/middlewares/jwt/jwt_token_auth.rs` | `email`: user email address | - | | `debug` | `JWT authentication failed: {e:?}` | `src/middlewares/jwt/jwt_middleware.rs` | `e`: caught error | - | | `debug` | `Key successfully unwrapped with wrapping key: {}` | `src/core/wrapping/unwrap.rs` | - | - | | `debug` | `Key wrap type: {:?}` | `src/core/operations/export_get.rs` | - | - | @@ -690,7 +688,6 @@ Crate path: `crate/server` | `error` | `JoinSplitKey: CO ceremony activation failed — rolling back reconstructed key from DB` | `src/core/operations/join_split_key.rs` | `uid` (reconstructed key UID), `user`, `session_id`, `error` (activation error) | Audit — compensating delete triggered; activation failure made the ceremony invalid; key is being removed | | `error` | `JoinSplitKey: CRITICAL — reconstructed key rollback failed; orphaned key remains in DB, manual cleanup required` | `src/core/operations/join_split_key.rs` | `uid` (orphaned key UID), `user`, `session_id`, `rollback_error` (delete error) | CRITICAL audit — DB is in inconsistent state; manual deletion of the `uid` object is required; alert SIEM | | `warn` | `` `force_default_username = true` combined with `privileged_users` is deprecated and will become an error in a future release. All requests run under the same identity, making Crypto Officer dual-control meaningless. Please migrate to `[roles] crypto_officer_users` and remove `force_default_username`. `` | `src/config/params/server_params.rs` | - | - | -| `info` | `ceremony sealing key loaded from object store` | `src/core/kms/mod.rs` | - | - | | `warn` | `[{idx}] CRL distribution point unreachable for '{:?}', skipping revocation check: {e}` | `src/core/operations/validate.rs` | `idx`, `e` | - | | `warn` | `CRL signature could not be verified against chain issuers; issuer: {crl_issuer:?}, path: {crl_path}. Continuing (trusted local delivery).` | `src/core/operations/validate.rs` | `crl_issuer`, `crl_path` | - | | `warn` | `CRL validation failed: {crl_err}` | `src/core/operations/validate.rs` | `crl_err` | - | @@ -705,6 +702,7 @@ Crate path: `crate/server` | `error` (audit) | `CRYPTO_OFFICER_ACCESS: crypto officer generating CRL (find_all bypass)` | `src/core/operations/generate_crl.rs` | `user`, `issuer_id` | Emitted every time a CO generates a CRL; always visible regardless of `RUST_LOG`. | | `info` | `Auto-CRL: triggered CRL regeneration for issuer '{issuer_id}' after certificate revocation` | `src/core/operations/revoke.rs` | `issuer_id`, `user` | Emitted on every successful auto-regen trigger. | | `warn` | `Auto-CRL: CRL regeneration failed for issuer '{issuer_id}': {e}` | `src/core/operations/revoke.rs` | `issuer_id`, `e` | Signing or DB error during auto-regen; Revoke still succeeds. | +| `error` (audit) | `CRYPTO_OFFICER_ACCESS: crypto officer generating CRL (find_all bypass)` | `src/core/operations/generate_crl.rs` | `user`, `issuer_id` | Emitted every time a CO generates a CRL; always visible regardless of `RUST_LOG`. | | `trace` | `Found {} revoked certificate(s) for issuer '{}'` | `src/core/operations/generate_crl.rs` | - | - | | `trace` | `Skipping certificate '{}': cannot parse DER: {e}` | `src/core/operations/generate_crl.rs` | `e` | - | | `warn` | `Failed to load CRL from database for issuer '{issuer_id}': {e}` | `src/core/operations/generate_crl.rs` | `issuer_id`, `e` | - | @@ -717,6 +715,15 @@ Crate path: `crate/server` | `debug` | `[crl-refresh-cron] Shutdown signal received; stopping` | `src/cron.rs` | - | - | | `debug` | `[kms-init] Failed to read max CRL number from DB: {e}; using unix timestamp as CRL counter seed` | `src/core/kms/mod.rs` | `e` | - | | `trace` | `Sorted candidate mismatch: cert AKI={}, SKI={}, sorted SKI={}, AKI={}` | `src/core/operations/validate.rs` | - | - | +| `error` | `CRYPTO_OFFICER_ACCESS: crypto officer generating CRL (find_all bypass)` | `src/core/operations/generate_crl.rs` | - | - | +| `warn` | `OPA request failed (fail-closed deny): {e}` | `src/core/opa/client.rs` | `e` | - | +| `warn` | `OPA response parse failed (fail-closed deny): {e}` | `src/core/opa/client.rs` | `e` | - | +| `warn` | `OPA returned non-2xx (fail-closed deny): {status} — {body_text}` | `src/core/opa/client.rs` | `status`, `body_text` | - | +| `warn` | `{:?} {} 401 unauthorized, no email or sub in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | +| `debug` | `JWT Access granted to {username}!` | `src/middlewares/jwt/jwt_token_auth.rs` | `username` | - | +| `trace` | `OPA enforcing decision for user={} op={} obj={}: {}` | `src/core/retrieve_object_utils.rs` | - | - | +| `trace` | `OPA exclusive decision for user={} op={} obj={}: {}` | `src/core/retrieve_object_utils.rs` | - | - | +| `info` | `ceremony sealing key loaded from object store` | `src/core/kms/mod.rs` | `ceremony_key_id`: KMS UID of the AES-256 ceremony sealing key | Confirms the sealing key was loaded successfully; key material is never logged. | | `info` | `GET /ocsp/ ({} bytes)` | `src/routes/ocsp/handler.rs` | - | - | | `info` | `POST /ocsp/ ({} bytes)` | `src/routes/ocsp/handler.rs` | - | - | | `debug` | `OCSP cache HIT` | `src/routes/ocsp/handler.rs` | - | - | diff --git a/documentation/docs/configuration/server_cli.md b/documentation/docs/configuration/server_cli.md index 080a1b81f4..8216bfbed3 100644 --- a/documentation/docs/configuration/server_cli.md +++ b/documentation/docs/configuration/server_cli.md @@ -579,15 +579,13 @@ Options: [env: KMS_CEREMONY_SECRET=] --ceremony-key-id - UID of a KMS symmetric key to use as the ceremony record sealing key. + UID of a KMS symmetric key to use as the ceremony record sealing key (ADP-26). - When set, key material is fetched from the KMS object store after database - initialization and used in place of `ceremony_secret`. This enables: + When set, key material is fetched from the KMS object store via a direct DB read + (bypassing KMIP auth) and used in place of `ceremony_secret`. This enables: - Key rotation via standard KMIP `ReKey` / `Rotate` operations. - HSM-backed sealing when the referenced key is HSM-resident. - - Audit trail: each retrieval of the ceremony key is logged. - - If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. + - Audit trail: each `Get` of the ceremony key is logged. **Bootstrap constraint**: the ceremony sealing key must be created before enabling `crypto_officer_require_ceremony = true`. Create it while the server @@ -601,29 +599,12 @@ Options: # 4. Enable require_ceremony = true and restart ``` - [env: KMS_CEREMONY_KEY_ID=] - - --ceremony-wrapping-key-id - UID of a KMS symmetric key to use for AES-KW (RFC 5649) wrapping of split-key shares. - - When set, `CreateSplitKey` encrypts each share's raw bytes with this key (AES-128/192/256-KWP) - before storing in the database. `JoinSplitKey` automatically detects the - `x-cosmian-share-wrapping-key` vendor attribute on each share and unwraps the bytes before - XOR reconstruction. - - The wrapping key must already exist in the KMS object store and must be an AES symmetric key. - When the KMS is HSM-backed, this key can be HSM-resident, providing hardware boundary - protection equivalent to purpose-built HSM split-key solutions. - - Generate a suitable key before enabling ceremony mode: - ```bash - ckms sym keys create --id ceremony-wrap-2026 --number-of-bits 256 - ``` + If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. - Rotate by creating a new key, updating this value, and re-running the ceremony - (existing wrapped shares require the original key; re-ceremony is mandatory on rotation). + **Status**: ADP-26 (planned). This field is accepted by the config parser but is not yet + functional. Set `ceremony_secret` in the meantime. - [env: KMS_CEREMONY_WRAP_KEY_ID=] + [env: KMS_CEREMONY_KEY_ID=] --aws-xks-enable This setting turns on endpoints handling the AWS XKS feature diff --git a/documentation/docs/configuration/server_configuration_file.md b/documentation/docs/configuration/server_configuration_file.md index 63e747a6a1..943a4ca186 100644 --- a/documentation/docs/configuration/server_configuration_file.md +++ b/documentation/docs/configuration/server_configuration_file.md @@ -498,69 +498,8 @@ vault_token_cache_ttl_secs = 0 # When `true`, users listed in `crypto_officer_users` are candidates only — # the role is inactive until a KMIP `JoinSplitKey` with all shares tagged # `x-cosmian-crypto-officer-ceremony` completes +# (NIST SP 800-57 Part 2 Rev 1 §4.6 split knowledge, XOR n-of-n). crypto_officer_require_ceremony = false - -# Users with the Crypto Officer role (ISO/IEC 19790 "Crypto Officer" / PKCS#11 `CKU_SO`). -# -# May manage key lifecycle (create, import, certify, rekey, activate, revoke, destroy) -# and access raw key material (get, export — "key output" per ISO/IEC 19790 §7.4.3). -# When active, gains ownership bypass on all Managed Objects. -# When set, only listed users (plus those explicitly granted the `Create` right) can -# create and import objects. -# crypto_officer_users = ["alice@example.com", "bob@example.com"] - -# Hex-encoded 32-byte secret for ceremony record encryption. -# -# Required when any role has `require_ceremony = true`. -# All ceremony activation records are AES-256-GCM encrypted with keys -# derived from this secret, preventing forgery via direct database writes -# and protecting participant identities at rest. -# -# Generate with: `openssl rand -hex 32` -# ceremony_secret = "" - -# UID of a KMS symmetric key to use as the ceremony record sealing key. -# -# When set, key material is fetched from the KMS object store after database -# initialization and used in place of `ceremony_secret`. This enables: -# - Key rotation via standard KMIP `ReKey` / `Rotate` operations. -# - HSM-backed sealing when the referenced key is HSM-resident. -# - Audit trail: each retrieval of the ceremony key is logged. -# -# If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. -# -# **Bootstrap constraint**: the ceremony sealing key must be created before -# enabling `crypto_officer_require_ceremony = true`. Create it while the server -# is in config-only CO mode (no ceremony required), then enable ceremony mode: -# -# ```bash -# # 1. Start server with require_ceremony = false -# # 2. Create the sealing key: -# ckms sym keys create --id ceremony-seal-2026 --number-of-bits 256 -# # 3. Set ceremony_key_id = "ceremony-seal-2026" in kms.toml -# # 4. Enable require_ceremony = true and restart -# ``` -# ceremony_key_id = "" - -# UID of a KMS symmetric key to use for AES-KW (RFC 5649) wrapping of split-key shares. -# -# When set, `CreateSplitKey` encrypts each share's raw bytes with this key (AES-128/192/256-KWP) -# before storing in the database. `JoinSplitKey` automatically detects the -# `x-cosmian-share-wrapping-key` vendor attribute on each share and unwraps the bytes before -# XOR reconstruction. -# -# The wrapping key must already exist in the KMS object store and must be an AES symmetric key. -# When the KMS is HSM-backed, this key can be HSM-resident, providing hardware boundary -# protection equivalent to purpose-built HSM split-key solutions. -# -# Generate a suitable key before enabling ceremony mode: -# ```bash -# ckms sym keys create --id ceremony-wrap-2026 --number-of-bits 256 -# ``` -# -# Rotate by creating a new key, updating this value, and re-running the ceremony -# (existing wrapped shares require the original key; re-ceremony is mandatory on rotation). -# ceremony_wrapping_key_id = "ceremony-wrap-key" ``` --- @@ -598,12 +537,10 @@ This means the origins in the allow-list must contain the **exact URL** the user types in the browser's address bar — scheme, hostname, and port all included. Configuring `0.0.0.0` (the bind address) or the server's IP address does **not** match a hostname-based origin such as `http://kms.example.com:9998`, and vice -versa. - -The binary automatically provides loopback addresses -(`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`, `[::]` on the configured port) so that -browser access from the same machine works out-of-the-box. Any other hostname, -IP address, or port must be added explicitly. +versa. Note that port 80 (HTTP) and port 443 (HTTPS) are the browser default +ports and are **omitted** from the `Origin` header, so the value must not +include them either (e.g. use `https://kms.example.com`, not +`https://kms.example.com:443`). CLI clients (`ckms`, scripts, curl) do not send an `Origin` header and are not affected by this setting. diff --git a/documentation/docs/hsm_support/hsm_operations.md b/documentation/docs/hsm_support/hsm_operations.md index 0de4a0483f..f33fff98a8 100644 --- a/documentation/docs/hsm_support/hsm_operations.md +++ b/documentation/docs/hsm_support/hsm_operations.md @@ -57,7 +57,7 @@ KMS_HSM_ADMIN=alice@example.com,bob@example.com cosmian_kms ... ownership and access-rights model for all other operations (`Encrypt`, `Decrypt`, `Get`, etc.). An HSM admin can therefore `grant` these operations to ordinary users, who can then use the HSM key without themselves being HSM admins. - See [HSM keys and authorization](../configuration/authorization.md#hsm-keys-and-authorization) for details. + See [HSM keys and authorization](../configuration/authorization/index.md) for details. ## HSM key authorization model diff --git a/documentation/docs/integrations/api.md b/documentation/docs/integrations/api.md index af4a7cce86..71e0d26533 100644 --- a/documentation/docs/integrations/api.md +++ b/documentation/docs/integrations/api.md @@ -9,7 +9,7 @@ This API is documented in the [KMIP section](../kmip_support/json_ttlv_api.md) o ### Calling the authorization API -This API is documented in the [authorization section](../configuration/authorization.md) of this manual. +This API is documented in the [authorization section](../configuration/authorization/index.md) of this manual. ## Authentication diff --git a/documentation/docs/integrations/cloud_providers/azure/ekm.md b/documentation/docs/integrations/cloud_providers/azure/ekm.md index 1a173a3da4..079bb77571 100644 --- a/documentation/docs/integrations/cloud_providers/azure/ekm.md +++ b/documentation/docs/integrations/cloud_providers/azure/ekm.md @@ -193,7 +193,7 @@ azure_ekm_disable_client_auth = false When Azure Managed HSM connects to the EKM proxy over mTLS, Eviden KMS authenticates it as a regular KMIP user, using the **Subject CN of the client certificate** as the username (see [TLS Client Certificate configuration](../../../configuration/configurations.md#tls-client-cert) and the [Authentication guide](../../../configuration/authentication.md)). This identity is generally of the form `.managedhsmclient.azure.net` — check your own Managed HSM client certificate to confirm its exact Subject CN. -Because the external key is owned by whichever KMS user created it, you must explicitly grant this Managed HSM identity the rights to read and use that key, using [`ckms access-rights grant`](../../../configuration/authorization.md): +Because the external key is owned by whichever KMS user created it, you must explicitly grant this Managed HSM identity the rights to read and use that key, using [`ckms access-rights grant`](../../../configuration/authorization/index.md): ```bash ckms access-rights grant .managedhsmclient.azure.net -i get get_attributes encrypt decrypt diff --git a/documentation/docs/use_cases/pki-revocation.md b/documentation/docs/use_cases/pki-revocation.md index 0c862ad4c9..4fd8f4db2c 100644 --- a/documentation/docs/use_cases/pki-revocation.md +++ b/documentation/docs/use_cases/pki-revocation.md @@ -152,6 +152,17 @@ is stored in the database. per-certificate revocation status — see [OCSP Responder](pki-ocsp.md). +## Authority Information Access (AIA) + +The AIA extension (`authorityInfoAccess`, OID `1.3.6.1.5.5.7.1.1`) can be added +via the extension config file to point relying parties to an OCSP responder or to +the CA issuer certificate: + +```ini +[ v3_ext ] +authorityInfoAccess=OCSP;URI:http://ocsp.example.com/,caIssuers;URI:http://ca.example.com/ca.crt +``` + ## No Revocation Available (`id-ce-noRevAvail`, RFC 9608) For **self-signed certificates** (no issuer key provided) that do not carry a CRL diff --git a/documentation/nav.yml b/documentation/nav.yml index c3b5cf0f86..f76d67a1a1 100644 --- a/documentation/nav.yml +++ b/documentation/nav.yml @@ -128,9 +128,14 @@ nav: - Kubernetes (Helm): installation/kubernetes_helm.md - High-availability: installation/high_availability_mode.md - Configuration: - - Configuration file: configuration/server_configuration_file.md - - Configuration examples: configuration/configurations.md - - Command line arguments: configuration/server_cli.md + - Server reference: + - Configuration file: configuration/server_configuration_file.md + - Configuration examples: configuration/configurations.md + - Command line arguments: configuration/server_cli.md + - Databases: + - Configuration: configuration/database/configuration.md + - Tables: configuration/database/tables.md + - Redis with Findex: configuration/database/redis.md - Databases: - Configuration: configuration/database/configuration.md - Tables: configuration/database/tables.md @@ -142,13 +147,20 @@ nav: - Object & Unwrapped Caches: configuration/object-cache.md - Authenticating users to the server: configuration/authentication.md - PKCE Authentication: configuration/pkce_authentication.md - - Authorizing users with access rights: - - Ownership and access rights: configuration/authorization.md - - Role Management and Key Ceremony: configuration/authorization/key_ceremony.md + - Authorization: + - Overview: configuration/authorization/index.md + - Mode 1 — Native KMS permissions: + - Native KMS permissions: configuration/authorization/mode1.md + - Role management and key ceremony: configuration/authorization/key_ceremony.md + - Mode 2 — Exclusive OPA (RBAC): configuration/authorization/mode2.md + - Mode 3 — Enforcing (OPA + KMS): + - Architecture and use cases: configuration/authorization/mode3.md + - OPA + Authentication Verifier setup: configuration/authorization/opa-authverifier-setup.md - Enabling TLS: configuration/tls.md - Obtaining TLS Certificates: configuration/certificates.md - - Logging and telemetry: configuration/logging.md - - Log reference: configuration/log-reference.md + - Logging and telemetry: + - Logging: configuration/logging.md + - Log reference: configuration/log-reference.md - Monitoring: - Setup: configuration/monitoring-setup.md - Metrics reference: configuration/otlp-metrics.md diff --git a/pkg/kms.toml b/pkg/kms.toml index 0b9a8aa6de..226daa41c8 100644 --- a/pkg/kms.toml +++ b/pkg/kms.toml @@ -414,65 +414,3 @@ vault_token_cache_ttl_secs = 0 # `x-cosmian-crypto-officer-ceremony` completes # (NIST SP 800-57 Part 2 Rev 1 §4.6 split knowledge, XOR n-of-n). crypto_officer_require_ceremony = false - -# Users with the Crypto Officer role (ISO/IEC 19790 "Crypto Officer" / PKCS#11 `CKU_SO`). -# -# May manage key lifecycle (create, import, certify, rekey, activate, revoke, destroy) -# and access raw key material (get, export — "key output" per ISO/IEC 19790 §7.4.3). -# When active, gains ownership bypass on all Managed Objects. -# When set, only listed users (plus those explicitly granted the `Create` right) can -# create and import objects. -# crypto_officer_users = ["alice@example.com", "bob@example.com"] - -# Hex-encoded 32-byte secret for ceremony record encryption. -# -# Required when any role has `require_ceremony = true`. -# All ceremony activation records are AES-256-GCM encrypted with keys -# derived from this secret, preventing forgery via direct database writes -# and protecting participant identities at rest. -# -# Generate with: `openssl rand -hex 32` -# ceremony_secret = "" - -# UID of a KMS symmetric key to use as the ceremony record sealing key. -# -# When set, key material is fetched from the KMS object store after database -# initialization and used in place of `ceremony_secret`. This enables: -# - Key rotation via standard KMIP `ReKey` / `Rotate` operations. -# - HSM-backed sealing when the referenced key is HSM-resident. -# - Audit trail: each retrieval of the ceremony key is logged. -# -# If both `ceremony_secret` and `ceremony_key_id` are set, `ceremony_key_id` takes precedence. -# -# **Bootstrap constraint**: the ceremony sealing key must be created before -# enabling `crypto_officer_require_ceremony = true`. Create it while the server -# is in config-only CO mode (no ceremony required), then enable ceremony mode: -# -# ```bash -# # 1. Start server with require_ceremony = false -# # 2. Create the sealing key: -# ckms sym keys create --id ceremony-seal-2026 --number-of-bits 256 -# # 3. Set ceremony_key_id = "ceremony-seal-2026" in kms.toml -# # 4. Enable require_ceremony = true and restart -# ``` -# ceremony_key_id = "" - -# UID of a KMS symmetric key to use for AES-KW (RFC 5649) wrapping of split-key shares. -# -# When set, `CreateSplitKey` encrypts each share's raw bytes with this key (AES-128/192/256-KWP) -# before storing in the database. `JoinSplitKey` automatically detects the -# `x-cosmian-share-wrapping-key` vendor attribute on each share and unwraps the bytes before -# XOR reconstruction. -# -# The wrapping key must already exist in the KMS object store and must be an AES symmetric key. -# When the KMS is HSM-backed, this key can be HSM-resident, providing hardware boundary -# protection equivalent to purpose-built HSM split-key solutions. -# -# Generate a suitable key before enabling ceremony mode: -# ```bash -# ckms sym keys create --id ceremony-wrap-2026 --number-of-bits 256 -# ``` -# -# Rotate by creating a new key, updating this value, and re-running the ceremony -# (existing wrapped shares require the original key; re-ceremony is mandatory on rotation). -# ceremony_wrapping_key_id = "ceremony-wrap-key" diff --git a/shell.nix b/shell.nix index 8d4b40f77d..137fdb6544 100644 --- a/shell.nix +++ b/shell.nix @@ -123,6 +123,11 @@ pkgs.mkShell { # zlib is needed on macOS in Nix pure mode (-nodefaultlibs strips system /usr/lib). # Including it here puts its path into NIX_LDFLAGS so the Nix cc-wrapper can find -lz. pkgs.zlib + # Provide the Nix-native mold linker so local developer cargo configs that set + # `-fuse-ld=mold` work inside the pure nix-shell. The system /usr/bin/ld.mold + # links against a newer libstdc++ (CXXABI_1.3.15) that is absent from the Nix + # gcc-13.3.0-lib; the Nix mold is ABI-compatible with the Nix toolchain. + pkgs.mold ] ++ ( if withWasm then @@ -255,9 +260,8 @@ pkgs.mkShell { unset NIX_LD_LIBRARY_PATH NIX_CFLAGS_COMPILE NIX_LDFLAGS || true fi else - export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.gcc.cc.lib}/lib:$OPENSSL_PKG_PATH/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.gcc.cc.lib}/lib:${pkgs.zlib}/lib:$OPENSSL_PKG_PATH/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" fi - # Preload bootstrap so even statically linked libcrypto gets providers + properties if [ -f "${opensslFipsBootstrap}/lib/libopenssl_fips_bootstrap.so" ]; then export LD_PRELOAD="${opensslFipsBootstrap}/lib/libopenssl_fips_bootstrap.so''${LD_PRELOAD:+:$LD_PRELOAD}" @@ -295,7 +299,7 @@ pkgs.mkShell { unset NIX_LD_LIBRARY_PATH NIX_CFLAGS_COMPILE NIX_LDFLAGS || true fi else - export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.gcc.cc.lib}/lib:$OPENSSL_PKG_PATH/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.gcc.cc.lib}/lib:${pkgs.zlib}/lib:$OPENSSL_PKG_PATH/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" fi fi diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..544c11fbc3 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fc85ee1bf3..77f145812e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -127,6 +127,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm const [isAuthLoading, setIsAuthLoading] = useState(true); const [authMethod, setAuthMethod] = useState(undefined); const [configuredMethods, setConfiguredMethods] = useState([]); + const [authVerifierRealms, setAuthVerifierRealms] = useState([]); const [loginError, setLoginError] = useState(undefined); useEffect(() => { @@ -166,14 +167,16 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm void syncVendorId(); const fetchUser = async () => { - const methods = await fetchAuthMethods(location); + const authConfig = await fetchAuthMethods(location); // `undefined` means the server was unreachable or the response could not // be parsed: leave `authMethod` undefined so the error UI is shown. - if (methods === undefined) { + if (authConfig === undefined) { setIsAuthLoading(false); return; } + const { methods, authVerifierRealms: realms } = authConfig; setConfiguredMethods(methods); + setAuthVerifierRealms(realms); // No authentication configured: render the app directly (MainLayout shows // the "authentication disabled" banner). @@ -282,6 +285,7 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm { setAuthMethod("CERT"); @@ -516,8 +520,8 @@ function App() { Layout: { headerBg: "#ffffff", footerPadding: "5px 50px", - /* Sider collapse trigger: transparent (matches sidebar bg) + accessible dark icon (≥4.5:1) */ - triggerBg: "#fafafa", + /* Sider collapse trigger: light gray bg + accessible dark icon (≥4.5:1) */ + triggerBg: "#e8eaed", triggerColor: "#595959", }, Card: { diff --git a/ui/src/actions/Access/AccessRevoke.tsx b/ui/src/actions/Access/AccessRevoke.tsx index ece29637c5..2546791e8f 100644 --- a/ui/src/actions/Access/AccessRevoke.tsx +++ b/ui/src/actions/Access/AccessRevoke.tsx @@ -1,10 +1,10 @@ import { Button, Card, Checkbox, Form, Input, Select, Space } from "antd"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getNoTTLVRequest, postNoTTLVRequest } from "../../utils/utils"; -import { useActionState } from "../../hooks/useActionState"; import { ActionResponse } from "../../components/common/ActionResponse"; import LocateButton from "../../components/common/LocateButton"; +import { useActionState } from "../../hooks/useActionState"; +import { getNoTTLVRequest, postNoTTLVRequest } from "../../utils/utils"; import * as wasm from "../../wasm/pkg"; interface AccessRevokeFormData { diff --git a/ui/src/actions/Access/CryptoOfficerRole.tsx b/ui/src/actions/Access/CryptoOfficerRole.tsx index 9e063bb253..3025838bf6 100644 --- a/ui/src/actions/Access/CryptoOfficerRole.tsx +++ b/ui/src/actions/Access/CryptoOfficerRole.tsx @@ -115,7 +115,7 @@ const CryptoOfficerRole: React.FC = () => { } catch (splitErr) { // Compensating delete: destroy the orphaned AES key before re-throwing try { - const destroyReq = wasm.destroy_ttlv_request(createdKeyId, true); + const destroyReq = wasm.destroy_ttlv_request(createdKeyId, false); await sendKmipRequest(destroyReq, serverUrl); } catch { /* best-effort; ignore cleanup errors */ @@ -284,13 +284,11 @@ const CryptoOfficerRole: React.FC = () => { - {/* Only CO candidates (active or dormant) see the revoke section. - Active COs can self-revoke (empty target) or peer-revoke. - Dormant candidates can only peer-revoke (button disabled otherwise). - Non-CO users are excluded: ceremony_activated is system-wide - (any CO active), but users who are not CO candidates have no - meaningful action here and should not see the controls. */} - {status.ceremony_activated && (status.is_crypto_officer || status.users.includes(userId ?? "")) && ( + {/* Any CO candidate (active or dormant) can revoke an active CO. + Active COs can also self-revoke by leaving the target empty. + Dormant candidates (in users list but no active activation) can + only peer-revoke — the button is disabled if no target is set. */} + {status.ceremony_activated && status.users.length > 0 && (

{t("cryptoOfficer.revokeRole")}

@@ -342,10 +340,8 @@ const CryptoOfficerRole: React.FC = () => { )} - {/* Ceremony workflow — shown when ceremony is required and the current user is not yet active. - With the per-user model multiple COs can be simultaneously active, so we gate on - `is_crypto_officer` (am I personally active?) not `ceremony_activated` (is anyone active?). */} - {status && status.enabled && status.require_ceremony && !status.is_crypto_officer && ( + {/* Ceremony workflow — only shown when ceremony mode is required and role is dormant */} + {status && status.enabled && status.require_ceremony && !status.ceremony_activated && ( <> {/* ── Step 1: Create & Split Key ────────────────────────────── */} diff --git a/ui/src/actions/Keys/JoinSplitKey.tsx b/ui/src/actions/Keys/JoinSplitKey.tsx index 2218ca59c6..615ad324c7 100644 --- a/ui/src/actions/Keys/JoinSplitKey.tsx +++ b/ui/src/actions/Keys/JoinSplitKey.tsx @@ -81,12 +81,6 @@ const JoinSplitKeyForm: React.FC = () => { }); }; - const initialValues = { - shareCount: DEFAULT_SHARE_COUNT, - objectType: "SymmetricKey" as const, - shareIds: Array.from({ length: DEFAULT_SHARE_COUNT }, () => ({ value: "" })), - }; - return (

{t("joinSplitKey.title")}

@@ -101,7 +95,16 @@ const JoinSplitKeyForm: React.FC = () => {
-
+ ({ value: "" })), + }} + > diff --git a/ui/src/actions/Keys/SplitKey.tsx b/ui/src/actions/Keys/SplitKey.tsx index a94f26560e..1a76a69e9b 100644 --- a/ui/src/actions/Keys/SplitKey.tsx +++ b/ui/src/actions/Keys/SplitKey.tsx @@ -56,7 +56,7 @@ const SplitKeyForm: React.FC = () => { } catch (splitErr) { // Compensating delete: destroy the orphaned AES key before re-throwing try { - const destroyReq = wasm.destroy_ttlv_request(createdKeyId, true); + const destroyReq = wasm.destroy_ttlv_request(createdKeyId, false); await sendKmipRequest(destroyReq, serverUrl); } catch { /* best-effort; ignore cleanup errors */ diff --git a/ui/src/actions/Objects/ObjectsReKey.tsx b/ui/src/actions/Objects/ObjectsReKey.tsx index 939b7c510c..4235e90a28 100644 --- a/ui/src/actions/Objects/ObjectsReKey.tsx +++ b/ui/src/actions/Objects/ObjectsReKey.tsx @@ -1,11 +1,11 @@ import { Button, Card, Form, Select, Space } from "antd"; +import type { TFunction } from "i18next"; import React from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; import { ActionResponse } from "../../components/common/ActionResponse"; +import KeyIdInput from "../../components/common/KeyIdInput"; import { useActionState } from "../../hooks/useActionState"; import { sendKmipRequest } from "../../utils/utils"; -import KeyIdInput from "../../components/common/KeyIdInput"; import { parse_rekey_keypair_ttlv_response, parse_rekey_ttlv_response, diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index b87afb0eea..2b49ea50c4 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import { DownOutlined } from "@ant-design/icons"; -import { Alert, Button, Dropdown, Input } from "antd"; +import { Alert, Button, Dropdown, Input, Select } from "antd"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -12,11 +12,17 @@ interface LoginProps { error?: undefined | string; /** Configured login methods, ordered by priority (primary first). */ authMethods?: AuthMethod[]; + /** + * Realms available for Auth Verifier username/password login. + * Empty when only one realm is configured (server uses its default). + * When non-empty the login form shows a realm selector dropdown. + */ + authVerifierRealms?: string[]; /** Called when a client-certificate probe succeeds; updates isAuthenticated in App. */ onCertAuthenticated?: () => void; } -const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthenticated }) => { +const LoginPage: React.FC = ({ auth, error, authMethods, authVerifierRealms = [], onCertAuthenticated }) => { // Keep only browser-login methods, preserving the server's priority order. const methods = (authMethods ?? []).filter((m): m is AuthMethod => m === "JWT" || m === "AUTH_VERIFIER" || m === "CERT"); const [selectedMethod, setSelectedMethod] = useState(methods[0]); @@ -27,6 +33,8 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe const [authVerifierTotpCode, setAuthVerifierTotpCode] = useState(""); const [authVerifierTotpRequired, setAuthVerifierTotpRequired] = useState(false); const [authVerifierError, setAuthVerifierError] = useState(null); + // Realm selector: only relevant when multiple realms are configured. + const [selectedRealm, setSelectedRealm] = useState(authVerifierRealms[0]); const { login, serverUrl } = useAuth(); const navigate = useNavigate(); const branding = useBranding(); @@ -104,6 +112,8 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe authVerifierUsername, authVerifierPassword, authVerifierTotpRequired ? authVerifierTotpCode : undefined, + // Send realm only when multiple realms are configured; omit to use server default. + authVerifierRealms.length > 1 ? selectedRealm : undefined, ); if (nextStep === "TotpRequired") { setAuthVerifierTotpRequired(true); @@ -140,7 +150,19 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe {certError && ( )} - {selectedMethod === "AUTH_VERIFIER" ? ( + {methods.length === 0 ? ( + + ) : selectedMethod === "AUTH_VERIFIER" ? (
{authVerifierError && ( = ({ auth, error, authMethods, onCertAuthe /> ) : ( <> + {authVerifierRealms.length > 1 && ( + => } }; +/** Result of `GET /ui/auth_method` — ordered method list plus optional realm list. */ +type AuthConfig = { + methods: AuthMethod[]; + /** Realm list for the Auth Verifier UI login form. Empty when not configured. */ + authVerifierRealms: string[]; +}; + /** - * Fetch the ordered list of configured UI login methods (primary first). + * Fetch the ordered list of configured UI login methods (primary first) together + * with any configured Auth Verifier realms. * * Reads the `auth_methods` array from `GET /ui/auth_method`. Falls back to the * singular `auth_method` field for older servers that don't yet return the array. - * Returns an empty array when authentication is disabled ("None") and `undefined` - * on network/parse failure (so callers can distinguish "no methods" from - * "server unreachable"). + * Returns an empty `methods` array when authentication is disabled ("None") and + * `undefined` on network/parse failure (so callers can distinguish "no methods" + * from "server unreachable"). */ /** * True only when CERT is the sole configured method, so auto-login via the @@ -85,10 +93,10 @@ export const fetchAuthMethod = async (serverUrl: string): Promise => */ export const shouldAutoLoginWithCert = (methods: AuthMethod[]): boolean => methods.length === 1 && methods[0] === "CERT"; -export const fetchAuthMethods = async (serverUrl: string): Promise => { +export const fetchAuthMethods = async (serverUrl: string): Promise => { // Skip the fetch in dev mode to avoid unnecessary friction (no auth enforced). if (import.meta.env.VITE_DEV_MODE === "true") { - return []; + return { methods: [], authVerifierRealms: [] }; } try { const kmsUrl = serverUrl + "/ui/auth_method"; @@ -98,16 +106,25 @@ export const fetchAuthMethods = async (serverUrl: string): Promise m !== undefined && m !== "None"); - } - // Backward-compatibility: older servers only return the singular field. - if (data.auth_method && data.auth_method !== "None") { - return [data.auth_method]; + methods = data.auth_methods.filter((m): m is AuthMethod => m !== undefined && m !== "None"); + } else if (data.auth_method && data.auth_method !== "None") { + // Backward-compatibility: older servers only return the singular field. + methods = [data.auth_method]; + } else { + methods = []; } - return []; + return { methods, authVerifierRealms }; } catch (error) { console.error(error); return undefined; @@ -123,12 +140,15 @@ type AuthVerifierLoginNextStep = "Authenticated" | "TotpRequired"; * session cookie; the AS's JWT never reaches the browser. * * Pass `totpCode` once the caller has already received a `"TotpRequired"` response. + * Pass `realm` when the server is configured with multiple realms; omit to use the + * server-side default (first configured realm). */ export const loginAuthVerifier = async ( serverUrl: string, username: string, password: string, totpCode?: string, + realm?: string, ): Promise => { const kmsUrl = serverUrl + "/ui/login_as"; const response = await fetch(kmsUrl, { @@ -137,7 +157,7 @@ export const loginAuthVerifier = async ( headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ username, password, totp_code: totpCode }), + body: JSON.stringify({ username, password, totp_code: totpCode, realm }), }); const data: unknown = await response.json().catch(() => null); diff --git a/ui/tests/e2e-auth/auth-verifier-login.spec.ts b/ui/tests/e2e-auth/auth-verifier-login.spec.ts index ce971e67c7..024a4e85b6 100644 --- a/ui/tests/e2e-auth/auth-verifier-login.spec.ts +++ b/ui/tests/e2e-auth/auth-verifier-login.spec.ts @@ -29,7 +29,13 @@ test.describe("Auth Verifier server — Web UI login", () => { expect(response.ok()).toBeTruthy(); // `auth_method` is the primary (backward-compatible); `auth_methods` is the // ordered array of all configured methods (primary first). - await expect(response.json()).resolves.toEqual({ auth_method: "AUTH_VERIFIER", auth_methods: ["AUTH_VERIFIER"] }); + // `auth_verifier_realms` lists the realm(s) configured in the KMS server — + // use toMatchObject so the test is resilient to the actual realm name(s). + await expect(response.json()).resolves.toMatchObject({ + auth_method: "AUTH_VERIFIER", + auth_methods: ["AUTH_VERIFIER"], + auth_verifier_realms: expect.any(Array), + }); }); test("TC1 — happy path login", async ({ page }) => { diff --git a/ui/tests/e2e/README.md b/ui/tests/e2e/README.md index fb2b3060fc..64212ad869 100644 --- a/ui/tests/e2e/README.md +++ b/ui/tests/e2e/README.md @@ -4,6 +4,7 @@ End-to-end tests validating the UI → WASM → KMIP → KMS pipeline. ## FIPS mode +Run `bash .github/scripts/nix.sh --variant fips test ui` to execute the suite Run `bash .github/scripts/nix.sh --variant fips test ui` to execute the suite against a FIPS-mode KMS server. Three spec files are automatically skipped in FIPS mode because they exercise algorithms that are not NIST-approved: @@ -742,3 +743,28 @@ Key facts verified by these tests: - The server accepts **all** KMIP protocol versions (1.0, 1.3, 1.4, 2.1) for backward compatibility. - The Swagger UI JS/CSS are served locally from the KMS server (no external CDN dependency). - The CSP enforces `default-src 'none'` with `'self'` allowed and `frame-ancestors 'none'` for clickjacking protection. + +## Authentication Login Page + +### login-page-auth-method-matrix + +10 tests verifying that `LoginPage` renders the correct UI for every combination of +authentication methods that `GET /ui/auth_method` can return. All tests mock the API +responses via `page.route()` and require no live KMS server — they run against the +Vite preview server only. + +| Test | `auth_methods` | Expected primary | Expected secondary | +| ---- | --------------------------------------------- | -------------------------------------- | -------------------------------- | +| 1 | `["AUTH_VERIFIER"]` | username/password form | none | +| 2 | `["JWT"]` | OIDC redirect button | none | +| 3 | `["CERT"]` | certificate button | none | +| 4 | `["JWT", "AUTH_VERIFIER"]` | OIDC button | AUTH_VERIFIER button | +| 5 | `["JWT", "CERT"]` | OIDC button | CERT button | +| 6 | `["AUTH_VERIFIER", "CERT"]` | form | CERT button | +| 7 | `["JWT", "AUTH_VERIFIER", "CERT"]` | OIDC button | secondary dropdown | +| 8 | `[]` | (no login form; redirect to `/locate`) | "authentication disabled" banner | +| 9 | `["AUTH_VERIFIER", "CERT"]` (secondary click) | probe fires immediately | navigates to `/locate` | +| 10 | live `GET /ui/auth_method` | `auth_method` equals `auth_methods[0]` | JSON shape validated | + +Key behaviour verified: clicking a "one-click" method (CERT or JWT) in a secondary +control fires the action immediately without first revealing the button as primary. diff --git a/ui/tests/e2e/login-page-auth-method-matrix.spec.ts b/ui/tests/e2e/login-page-auth-method-matrix.spec.ts new file mode 100644 index 0000000000..9dd7a84220 --- /dev/null +++ b/ui/tests/e2e/login-page-auth-method-matrix.spec.ts @@ -0,0 +1,309 @@ +/** + * Login-page auth-method matrix — Playwright E2E tests. + * + * These tests verify that `LoginPage` renders the correct UI for every + * combination of authentication methods that the KMS server can report. + * They work by intercepting `GET /ui/auth_method` (and `GET /ui/whoami`) + * so they run against the Vite dev/preview server with NO live KMS required. + * + * Combinations tested (8 standard + 1 OPA-config scenario): + * 1. ["AUTH_VERIFIER"] — username/password form, no secondary + * 2. ["JWT"] — OIDC button, no secondary + * 3. ["CERT"] — certificate button, no secondary + * 4. ["JWT", "AUTH_VERIFIER"] — OIDC primary, AUTH_VERIFIER secondary button + * 5. ["JWT", "CERT"] — OIDC primary, CERT secondary button + * 6. ["AUTH_VERIFIER", "CERT"] — form primary, CERT secondary button (no realms) + * 7. ["JWT", "AUTH_VERIFIER", "CERT"]— OIDC primary, secondary dropdown (2 entries) + * 8. [] (no auth) — no login form shown; no-auth banner in app + * 11. OPA RBAC (opa.toml) — ["AUTH_VERIFIER","CERT"] + 2 realms → form + + * realm selector + CERT secondary button + * + * Each test mocks: + * GET /ui/auth_method → { auth_method: , auth_methods: [...] } + * GET /ui/whoami → 401 (not authenticated, so login page is shown) + * GET /kmip/2_1 → ignored (not called during login page render) + * + * data-testid selectors (from LoginPage.tsx): + * auth-verifier-login-form — the username/password form + * auth-verifier-username-input + * auth-verifier-password-input + * oidc-login-btn — OIDC / JWT redirect button + * cert-login-btn — client certificate probe button + * login-secondary-btn — single secondary action button + * login-secondary-dropdown — dropdown when ≥ 2 secondary methods + * no-browser-auth-notice — shown when no browser-compatible method exists + */ + +import { expect, test } from "@playwright/test"; +import { UI_READY_TIMEOUT } from "./helpers"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +type AuthMethods = Array<"JWT" | "AUTH_VERIFIER" | "CERT">; + +/** + * Mock `/ui/auth_method` to return the given ordered list of methods. + * Also mock `/ui/whoami` to return 401 so the app shows the login page + * (not the already-authenticated redirect). + */ +async function mockAuthMethods(page: import("@playwright/test").Page, methods: AuthMethods) { + const primary = methods[0] ?? "None"; + await page.route("**/ui/auth_method", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ auth_method: primary, auth_methods: methods, auth_verifier_realms: [] }), + }), + ); + // 401 so the bootstrap code doesn't consider the user already logged in. + await page.route("**/ui/whoami", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); + // Silence fire-and-forget bootstrap calls. + await page.route("**/kmip/2_1", (route) => route.fulfill({ status: 401, body: "" })); + await page.route("**/version", (route) => route.fulfill({ status: 200, body: '"5.x.0"' })); + // 401 on cert probe so auto-cert-login doesn't fire. + await page.route("**/access/create", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); + await page.route("**/access/privileged", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); +} + +/** Navigate to /ui/login and wait until the login card is visible. */ +async function gotoLogin(page: import("@playwright/test").Page) { + await page.goto("/ui/login"); + // Wait for React to mount and for the auth-method fetch to complete. + // We wait for any of the login-form elements to appear (mocked auth method + // determines which one) rather than a generic selector. + await page.waitForFunction( + () => { + const sel = + "[data-testid='auth-verifier-login-form'],[data-testid='oidc-login-btn'],[data-testid='cert-login-btn'],[data-testid='no-browser-auth-notice']"; + return document.querySelector(sel) !== null; + }, + { timeout: 15_000 }, + ); +} + +// ── Matrix tests ────────────────────────────────────────────────────────────── + +// These tests mock GET /ui/auth_method and related auth bootstrap endpoints at +// the browser level. They are login-page rendering tests that require the UI to +// be built WITHOUT VITE_DEV_MODE=true. +// +// In CI the UI is always built with VITE_DEV_MODE=true, which makes +// fetchAuthMethods() return [] immediately without making a network call. +// In that mode the login page is never rendered, so these mocks are ineffective. +// +// The tests are skipped by default (not enabled via PLAYWRIGHT_AUTH_MATRIX_TESTS). +// To run them locally: +// CI=true pnpm run test:e2e --grep "Login page auth-method matrix" +// against a Vite preview built WITHOUT VITE_DEV_MODE=true. + +const authMatrixEnabled = typeof process !== "undefined" && process.env?.PLAYWRIGHT_AUTH_MATRIX_TESTS === "true"; + +test.describe("Login page auth-method matrix", () => { + test.skip(!authMatrixEnabled, "Opt-in tests: set PLAYWRIGHT_AUTH_MATRIX_TESTS=true when running against a non-dev-mode Vite preview"); + // ── 1. AUTH_VERIFIER only ───────────────────────────────────────────────── + test('["AUTH_VERIFIER"] — shows username/password form, no secondary', async ({ page }) => { + await mockAuthMethods(page, ["AUTH_VERIFIER"]); + await gotoLogin(page); + + await expect(page.getByTestId("auth-verifier-login-form")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-username-input")).toBeVisible(); + await expect(page.getByTestId("auth-verifier-password-input")).toBeVisible(); + await expect(page.getByTestId("oidc-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-btn")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 2. JWT only ─────────────────────────────────────────────────────────── + test('["JWT"] — shows OIDC redirect button, no secondary', async ({ page }) => { + await mockAuthMethods(page, ["JWT"]); + await gotoLogin(page); + + await expect(page.getByTestId("oidc-login-btn")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-btn")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 3. CERT only ────────────────────────────────────────────────────────── + test('["CERT"] — shows certificate button, no secondary', async ({ page }) => { + await mockAuthMethods(page, ["CERT"]); + await gotoLogin(page); + + await expect(page.getByTestId("cert-login-btn")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("oidc-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-btn")).not.toBeVisible(); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 4. JWT + AUTH_VERIFIER ──────────────────────────────────────────────── + test('["JWT", "AUTH_VERIFIER"] — OIDC primary, AUTH_VERIFIER secondary button', async ({ page }) => { + await mockAuthMethods(page, ["JWT", "AUTH_VERIFIER"]); + await gotoLogin(page); + + await expect(page.getByTestId("oidc-login-btn")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + + // Single secondary: a plain button labelled with the method name + const secondary = page.getByTestId("login-secondary-btn"); + await expect(secondary).toBeVisible(); + await expect(secondary).toContainText(/auth.*verifier|username.*password|sign.*in/i); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 5. JWT + CERT ───────────────────────────────────────────────────────── + test('["JWT", "CERT"] — OIDC primary, CERT secondary button', async ({ page }) => { + await mockAuthMethods(page, ["JWT", "CERT"]); + await gotoLogin(page); + + await expect(page.getByTestId("oidc-login-btn")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + + const secondary = page.getByTestId("login-secondary-btn"); + await expect(secondary).toBeVisible(); + await expect(secondary).toContainText(/certificate|cert/i); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 6. AUTH_VERIFIER + CERT ─────────────────────────────────────────────── + test('["AUTH_VERIFIER", "CERT"] — form primary, CERT secondary button', async ({ page }) => { + await mockAuthMethods(page, ["AUTH_VERIFIER", "CERT"]); + await gotoLogin(page); + + await expect(page.getByTestId("auth-verifier-login-form")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("oidc-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + + const secondary = page.getByTestId("login-secondary-btn"); + await expect(secondary).toBeVisible(); + await expect(secondary).toContainText(/certificate|cert/i); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); + + // ── 7. JWT + AUTH_VERIFIER + CERT ───────────────────────────────────────── + test('["JWT", "AUTH_VERIFIER", "CERT"] — OIDC primary, secondary dropdown with 2 entries', async ({ page }) => { + await mockAuthMethods(page, ["JWT", "AUTH_VERIFIER", "CERT"]); + await gotoLogin(page); + + await expect(page.getByTestId("oidc-login-btn")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + + // Two secondaries → dropdown control instead of a single button + await expect(page.getByTestId("login-secondary-dropdown")).toBeVisible(); + await expect(page.getByTestId("login-secondary-btn")).not.toBeVisible(); + }); + + // ── 8. No auth methods ──────────────────────────────────────────────────── + test("[] (no methods) — main layout shown directly, authentication disabled notice visible", async ({ page }) => { + await mockAuthMethods(page, []); + // Silence sidebar requests that MainLayout fires once authenticated. + await page.route("**/access/create", (route) => route.fulfill({ status: 200, body: '{"has_create_permission":false}' })); + await page.route("**/access/privileged", (route) => route.fulfill({ status: 200, body: '{"has_privileged_access":false}' })); + await page.route("**/version", (route) => route.fulfill({ status: 200, body: '"5.x.0"' })); + await page.route("**/ui/me", (route) => route.fulfill({ status: 200, body: '"default_user"' })); + + // When auth_methods = [], App shows the main layout immediately + // (route guard is false) and /login redirects to /locate. + await page.goto("/ui/login"); + await page.waitForURL(/\/ui\/locate/, { timeout: UI_READY_TIMEOUT }); + + // MainLayout renders the "authentication disabled" banner (i18n key authDisabledTitle). + await expect(page.getByText(/Authentication is disabled on this KMS server/i)).toBeVisible({ timeout: UI_READY_TIMEOUT }); + + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + await expect(page.getByTestId("oidc-login-btn")).not.toBeVisible(); + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + }); + + // ── 9. Switching methods via secondary button ───────────────────────────── + test("clicking secondary CERT button immediately fires the cert probe and navigates on success", async ({ page }) => { + await mockAuthMethods(page, ["AUTH_VERIFIER", "CERT"]); + // Probe: 200 means a valid client cert was presented → user is authenticated. + await page.route("**/access/create", (route) => route.fulfill({ status: 200, body: '{"has_create_permission":false}' })); + // Silence MainLayout bootstrap calls after cert auth succeeds. + await page.route("**/access/privileged", (route) => route.fulfill({ status: 200, body: '{"has_privileged_access":false}' })); + await page.route("**/version", (route) => route.fulfill({ status: 200, body: '"5.x.0"' })); + await page.route("**/ui/me", (route) => route.fulfill({ status: 200, body: '"cert_user"' })); + await gotoLogin(page); + + // Initially: AUTH_VERIFIER form is shown + await expect(page.getByTestId("auth-verifier-login-form")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + + // Click the secondary "Client certificate" button. + // `selectMethod("CERT")` fires handleAccessKms() immediately — it does NOT + // change the visible form first; it probes /access/create and, on success, + // calls onCertAuthenticated() which triggers App.tsx to show the main layout. + await page.getByTestId("login-secondary-btn").click(); + + // After a successful cert probe the app navigates to the main authenticated layout. + await page.waitForURL(/\/ui\/locate/, { timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-login-form")).not.toBeVisible(); + }); + + // ── 10. /ui/auth_method returns auth_methods order is preserved ─────────── + test("GET /ui/auth_method — auth_methods array is returned in configured order", async ({ request, baseURL }) => { + // Validate the endpoint contract (does NOT mock: verifies the dev server + // responds with the expected JSON shape for whatever is configured). + const resp = await request.get(`${baseURL}/ui/auth_method`); + expect(resp.ok()).toBe(true); + const body = await resp.json(); + expect(body).toHaveProperty("auth_method"); + expect(body).toHaveProperty("auth_methods"); + expect(Array.isArray(body.auth_methods)).toBe(true); + // The singular field must equal the first element of the array (or "None") + const expectedPrimary = (body.auth_methods as string[])[0] ?? "None"; + expect(body.auth_method).toBe(expectedPrimary); + }); + + // ── 11. OPA RBAC config scenario (opa.toml) ─────────────────────────────── + // Mirrors the exact server response produced by opa.toml: + // - auth_verifier configured → AUTH_VERIFIER in auth_methods + // - clients_ca_cert_file set → CERT in auth_methods + // - auth_verifier_realm = ["acme.com","partner.acme.com"] → realm selector + // Priority order: AUTH_VERIFIER (primary) → CERT (secondary single button) + test('OPA RBAC config ["AUTH_VERIFIER","CERT"] + 2 realms — form + realm selector + CERT secondary', async ({ page }) => { + // Mock the exact response shape that opa.toml produces at runtime. + await page.route("**/ui/auth_method", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + auth_method: "AUTH_VERIFIER", + auth_methods: ["AUTH_VERIFIER", "CERT"], + auth_verifier_realms: ["acme.com", "partner.acme.com"], + }), + }), + ); + await page.route("**/ui/whoami", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); + await page.route("**/kmip/2_1", (route) => route.fulfill({ status: 401, body: "" })); + await page.route("**/version", (route) => route.fulfill({ status: 200, body: '"5.x.0"' })); + await page.route("**/access/create", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); + await page.route("**/access/privileged", (route) => route.fulfill({ status: 401, body: "Unauthorized" })); + + await gotoLogin(page); + + // Primary: AUTH_VERIFIER form is rendered. + await expect(page.getByTestId("auth-verifier-login-form")).toBeVisible({ timeout: UI_READY_TIMEOUT }); + await expect(page.getByTestId("auth-verifier-username-input")).toBeVisible(); + await expect(page.getByTestId("auth-verifier-password-input")).toBeVisible(); + + // Realm selector appears because 2 realms are configured (> 1 → dropdown). + await expect(page.getByTestId("auth-verifier-realm-select")).toBeVisible(); + + // No OIDC button (JWT not in auth_methods). + await expect(page.getByTestId("oidc-login-btn")).not.toBeVisible(); + // CERT button is NOT shown as primary (it is the secondary method). + await expect(page.getByTestId("cert-login-btn")).not.toBeVisible(); + + // Secondary: single alternative → plain button (not a dropdown). + const secondary = page.getByTestId("login-secondary-btn"); + await expect(secondary).toBeVisible(); + await expect(secondary).toContainText(/certificate|cert/i); + await expect(page.getByTestId("login-secondary-dropdown")).not.toBeVisible(); + }); +}); diff --git a/ui/tests/unit/tsx-imports/CryptoOfficerRevoke.test.ts b/ui/tests/unit/tsx-imports/CryptoOfficerRevoke.test.ts index 309bba647c..39c65ac043 100644 --- a/ui/tests/unit/tsx-imports/CryptoOfficerRevoke.test.ts +++ b/ui/tests/unit/tsx-imports/CryptoOfficerRevoke.test.ts @@ -90,22 +90,18 @@ describe("CO revocation: dormant CO candidate can peer-revoke", () => { mockStatus({ ...baseActiveStatus, is_crypto_officer: false, - // active_co_users contains alice; current user (bob) is a dormant CO candidate + // active_co_users contains alice; current user (bob/carol) is dormant }), ); - // smokeRender is called with initialUserId so that status.users.includes(userId) is true. - // Without it, userId is null (AuthContext default) and the revoke controls are hidden — - // intentionally: users who are not yet identified should not see CO revoke controls. - test("renders the peer-revoke button for a dormant CO candidate", async () => { - smokeRender(React.createElement(CryptoOfficerRole), { initialUserId: "bob@example.com" }); + smokeRender(React.createElement(CryptoOfficerRole)); await screen.findByTestId("disable-btn"); expect(screen.getByTestId("disable-btn")).toBeInTheDocument(); }); test("peer-revoke button is disabled when no target is selected", async () => { - smokeRender(React.createElement(CryptoOfficerRole), { initialUserId: "bob@example.com" }); + smokeRender(React.createElement(CryptoOfficerRole)); const btn = await screen.findByTestId("disable-btn"); expect(btn).toBeDisabled(); });