Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ CORE_APPLICATION_URL=http://localhost:${HUB_UI_PORT:-8088}/api \
CONFIG=camunda-hub npm run test:pw:request-validation
```

The default profile runs the 400s. To run the 401 (`secured`) or 403 (`rbac`) negatives, set `RV_PROFILE`:

```bash
# 401 — auth-absent / auth-invalid (no BEARER_TOKEN needed; the suite sends none / a bad one)
RV_PROFILE=secured \
CORE_APPLICATION_URL=http://localhost:${HUB_UI_PORT:-8088}/api \
CONFIG=camunda-hub npm run test:pw:request-validation

# 403 — auth-deny. Mint a token from the reduced-permission deny client and pass it
# as RBAC_DENY_PROBE_BEARER_TOKEN. It authenticates (audience mapper added by
# start-hub.sh) but holds no public-api authority, so every secured op returns 403.
DENY_TOKEN=$(curl -s -X POST "http://localhost:${KEYCLOAK_PORT:-18080}/auth/realms/camunda-platform/protocol/openid-connect/token" \
-d "client_id=c8-client-deny&client_secret=c8-deny-secret&grant_type=client_credentials" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")

RV_PROFILE=rbac \
RBAC_DENY_PROBE_BEARER_TOKEN=$DENY_TOKEN \
CORE_APPLICATION_URL=http://localhost:${HUB_UI_PORT:-8088}/api \
CONFIG=camunda-hub npm run test:pw:request-validation
```

> The Hub server must be running with `CAMUNDA_MODELER_FEATURE_PUBLIC_API_V2_ENABLED=true` — set this in `camunda-hub/restapi/config/config-common/src/main/resources/application-common-local.yml` before starting.

### Running the Test Generator
Expand Down
5 changes: 3 additions & 2 deletions configs/camunda-hub/request-validation.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$comment": "Per-config request-validation settings for camunda-hub. enumCaseInsensitive: hub does not (yet) declare a position on enum case-insensitivity; default `false` emits case-only mutations as 400-expecting tests (conservative). authAbsentMode: 'all-secured' — the Hub Public API v2 is uniformly Bearer-authenticated via a single global `security: [{ bearerAuth: [] }]` requirement and declares no `x-enforcement: conditional` schemes, so the 401 auth-absent surface is every secured op (camunda/camunda-hub#25264).",
"$comment": "Per-config request-validation settings for camunda-hub. enumCaseInsensitive: hub does not (yet) declare a position on enum case-insensitivity; default `false` emits case-only mutations as 400-expecting tests (conservative). authAbsentMode: 'all-secured' — the Hub Public API v2 is uniformly Bearer-authenticated via a single global `security: [{ bearerAuth: [] }]` requirement and declares no `x-enforcement: conditional` schemes, so the 401 auth-absent surface is every secured op. authDenyMode: 'all-secured' — Hub authorizes via `hasAuthority(...) && hasAccessToOrganization(...)`, with the authority check short-circuiting before any resource lookup, so a reduced-permission Bearer probe is denied 403 on every secured op with dummy keys (no fixtures) (camunda/camunda-hub#25264).",
"enumCaseInsensitive": false,
"authAbsentMode": "all-secured"
"authAbsentMode": "all-secured",
"authDenyMode": "all-secured"
}
11 changes: 11 additions & 0 deletions docker/docker-compose.hub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ services:
KEYCLOAK_CLIENTS_0_PERMISSIONS_2_DEFINITION: update:*
KEYCLOAK_CLIENTS_0_PERMISSIONS_3_RESOURCE_SERVER_ID: web-modeler-public-api
KEYCLOAK_CLIENTS_0_PERMISSIONS_3_DEFINITION: delete:*
# Reduced-permission M2M client used by the rbac (403 auth-deny) profile.
# It is granted NO web-modeler-public-api authorities, so it authenticates
# but is denied on every secured op (403). Identity derives a token's
# audience from its granted permissions, so a zero-permission client would
# not carry aud=web-modeler-api and would 401 instead — start-hub.sh adds an
# explicit web-modeler-api audience mapper to this client so its token
# passes authentication and reaches the authorization (403) gate.
KEYCLOAK_CLIENTS_1_NAME: c8-client-deny
KEYCLOAK_CLIENTS_1_ID: c8-client-deny
KEYCLOAK_CLIENTS_1_SECRET: c8-deny-secret
KEYCLOAK_CLIENTS_1_TYPE: M2M

websockets:
image: ${WEBSOCKETS_IMAGE:-registry.camunda.cloud/team-hub/hub-websockets:SNAPSHOT} # set WEBSOCKETS_IMAGE to pin to a specific digest
Expand Down
35 changes: 35 additions & 0 deletions docker/start-hub.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@ for m in json.load(sys.stdin):
}" > /dev/null
echo "Keycloak: fixed web-modeler audience mapper → web-modeler-api"

# 1b. Give the reduced-permission deny client (c8-client-deny) a web-modeler-api
# audience. Identity derives a token's audience from its granted public-api
# permissions; this client has none (so it is denied 403), which would also
# leave its token without aud=web-modeler-api and make the restapi reject it
# with 401. Adding the audience mapper explicitly lets the token authenticate
# and reach the authorization (403) gate — the whole point of the rbac probe.
local deny_client_uuid
deny_client_uuid=$(curl -sf -H "Authorization: Bearer ${admin_token}" \
"${realm_url}/clients?clientId=c8-client-deny" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
if [ -n "$deny_client_uuid" ]; then
# POST is idempotent enough for our purposes: a 409 (mapper already exists from
# a prior run) is fine, anything else is surfaced by the next token check.
curl -s -o /dev/null -X POST \
-H "Authorization: Bearer ${admin_token}" \
-H "Content-Type: application/json" \
"${realm_url}/clients/${deny_client_uuid}/protocol-mappers/models" \
-d '{
"name": "web-modeler-api audience (deny client)",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "web-modeler-api",
"id.token.claim": "false",
"access.token.claim": "true",
"introspection.token.claim": "true",
"userinfo.token.claim": "false"
}
}'
echo "Keycloak: added web-modeler-api audience mapper to c8-client-deny"
else
echo "Warning: c8-client-deny not found — rbac (403) deny tests will not authenticate."
fi

# 2. Assign Web Modeler / Web Modeler Admin / Identity roles to the demo user.
# Identity creates the roles but does not assign them to seeded users.
local demo_user_id
Expand Down
12 changes: 8 additions & 4 deletions request-validation/scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async function main() {
let rvConfig: RequestValidationConfig = {
enumCaseInsensitive: false,
authAbsentMode: 'conditional',
authDenyMode: 'slice',
};
if (repoRoot) {
configName = getActiveConfigName(repoRoot);
Expand All @@ -149,7 +150,7 @@ async function main() {
}
console.log(
`[generate] Active config: ${configName} ` +
`(enumCaseInsensitive=${rvConfig.enumCaseInsensitive}, authAbsentMode=${rvConfig.authAbsentMode})`,
`(enumCaseInsensitive=${rvConfig.enumCaseInsensitive}, authAbsentMode=${rvConfig.authAbsentMode}, authDenyMode=${rvConfig.authDenyMode})`,
);
const { specPath, specProvenance, source } = resolveSpecSource();
console.log(`[generate] Using spec from ${source}: ${specPath}`);
Expand Down Expand Up @@ -246,13 +247,16 @@ async function main() {
}),
);
}
// auth-deny (HTTP 403) scenarios are read-side RBAC deny-tests (#359). Like
// auth-absent they depend only on the operation, not body/parameter shape, and
// are emitted exclusively into the `rbac` profile (see profile split below).
// auth-deny (HTTP 403) scenarios are RBAC deny-tests. In the default `slice`
// mode they are the OCA read-side allowlist (#359); under `authDenyMode:
// 'all-secured'` (Hub) they cover every secured op with a reduced-permission
// Bearer probe. Either way they depend only on the operation, not body/parameter
// shape, and are emitted exclusively into the `rbac` profile (see profile split).
if (wantKind('auth-deny')) {
scenarios.push(
...generateAuthDeny(model.operations, {
onlyOperations: opts.onlyOperations,
allSecured: rvConfig.authDenyMode === 'all-secured',
}),
);
}
Expand Down
81 changes: 74 additions & 7 deletions request-validation/src/analysis/authDeny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,39 @@ import { makeId } from './common.js';

interface Opts {
onlyOperations?: Set<string>;
/**
* When `true` (config `authDenyMode: 'all-secured'`), target every operation
* whose effective `security` mandates auth (`OperationModel.secured`) with
* dummy path keys, instead of the hardcoded read-side {@link SLICE}. The deny
* probe is a reduced-permission Bearer token (the emitter still renders
* `denyProbeHeaders()`, which switches to Bearer when
* `RBAC_DENY_PROBE_BEARER_TOKEN` is set — see the support `env.ts`), and no
* fixtures are provisioned. For an API whose authority check short-circuits
* before any resource lookup (e.g. Camunda Hub), a principal lacking the
* operation's permission is denied with 403 regardless of the (dummy) key.
* Default `false` preserves the OCA read-side slice behaviour.
*/
allSecured?: boolean;
}

/**
* Generate auth-deny (HTTP 403) scenarios for the `rbac` profile.
*
* Read-side RBAC deny-tests: for a get-by-key read endpoint, issue the request
* AS A FRESHLY-PROVISIONED NON-ADMIN USER WITH ZERO GRANTS (rendered as
* `denyProbeHeaders()` by the emitter, NOT the admin `authHeaders()`), and expect
* an authorizations-enabled server to deny it. The probe user and the target
* resources are created by the suite global-setup; the key references a
* KNOWN-EXISTING resource so the response is a genuine authorization deny (admin
* would see it at 200) rather than a 404-not-found that any caller would get.
* Two modes, selected by {@link Opts.allSecured}:
*
* - **slice** (default, OCA) — read-side RBAC deny-tests: for a get-by-key read
* endpoint in {@link SLICE}, issue the request AS A FRESHLY-PROVISIONED
* NON-ADMIN USER WITH ZERO GRANTS (rendered as `denyProbeHeaders()`, NOT the
* admin `authHeaders()`), and expect an authorizations-enabled server to deny
* it. The probe user and the target resources are created by the suite
* global-setup; the key references a KNOWN-EXISTING resource so the response is
* a genuine authorization deny (admin would see it at 200) rather than a
* 404-not-found that any caller would get.
*
* - **all-secured** (Hub) — one deny-test per `secured` operation with dummy path
* keys, authenticated as a reduced-permission Bearer probe token. Relies on the
* target authorizing before any resource lookup, so a dummy key still yields a
* clean 403 with no fixtures. See {@link generateAuthDenyAllSecured}.
*
* "Generic" = we assert the endpoint is permission-gated without naming the
* exact permission. Precise per-permission deny/allow pairs, and search/list
Expand Down Expand Up @@ -51,6 +72,7 @@ const SLICE: Record<string, Record<string, string>> = {
const DENY_STATUS = 403;

export function generateAuthDeny(ops: OperationModel[], opts: Opts): ValidationScenario[] {
if (opts.allSecured) return generateAuthDenyAllSecured(ops, opts);
const out: ValidationScenario[] = [];
for (const op of ops) {
if (opts.onlyOperations && !opts.onlyOperations.has(op.operationId)) continue;
Expand All @@ -71,3 +93,48 @@ export function generateAuthDeny(ops: OperationModel[], opts: Opts): ValidationS
}
return out;
}

/**
* `all-secured` deny generator (Hub): one 403 scenario per `secured` operation,
* with dummy path keys and no request body. The reduced-permission Bearer probe
* lacks the operation's required authority, so a target that checks authority
* before any resource lookup denies it with 403 regardless of the key.
*
* Mirrors the `secured` targeting of the 401 generators (auth-absent/auth-invalid)
* so the 403 surface lines up with the 401 surface. `security: []` / anonymous
* `{}` operations are excluded (they carry no auth requirement and would not 403).
*/
function generateAuthDenyAllSecured(ops: OperationModel[], opts: Opts): ValidationScenario[] {
const out: ValidationScenario[] = [];
for (const op of ops) {
if (opts.onlyOperations && !opts.onlyOperations.has(op.operationId)) continue;
if (op.secured !== true) continue;
out.push({
id: makeId([op.operationId, 'auth-deny']),
operationId: op.operationId,
method: op.method,
path: op.path,
type: 'auth-deny',
params: buildDummyParams(op.path),
expectedStatus: DENY_STATUS,
description:
'Request by a principal lacking the required permission is denied with 403 (rbac mode)',
// Not admin auth — the emitter renders denyProbeHeaders(), which uses the
// reduced-permission Bearer probe token in this mode.
headersAuth: false,
});
}
return out;
}

// Path params get a fixed dummy value: in all-secured mode the authority check
// short-circuits before any resource lookup, so the key is never dereferenced.
function buildDummyParams(path: string): Record<string, string> | undefined {
const m = path.match(/\{([^}]+)}/g);
if (!m) return undefined;
const params: Record<string, string> = {};
for (const token of m) {
params[token.slice(1, -1)] = 'x';
}
return params;
}
28 changes: 28 additions & 0 deletions request-validation/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,30 @@ export interface RequestValidationConfig {
* `x-enforcement`), this emits a no-credentials → 401 probe per secured op.
*/
authAbsentMode: 'conditional' | 'all-secured';
/**
* Which operations the 403 generator (auth-deny) targets, and how the deny
* probe authenticates, in the `rbac` profile.
*
* - `'slice'` (default) — the OCA read-side model: a hardcoded allowlist of
* get-by-key reads (`authDeny.ts`'s `SLICE`), authenticated as a Basic-auth
* zero-grant probe USER that the suite global-setup provisions, against
* fixtures it also creates (so the rejection is an authorization decision,
* not a 404-not-found).
* - `'all-secured'` — every operation whose effective `security` mandates auth
* (`OperationModel.secured`), authenticated as a reduced-permission Bearer
* probe TOKEN (`RBAC_DENY_PROBE_BEARER_TOKEN`), with dummy path keys and no
* fixtures. For an API whose authority check short-circuits before any
* resource lookup (e.g. Camunda Hub's
* `hasAuthority(...) && hasAccessToOrganization(...)`), a principal lacking
* the operation's permission is rejected with 403 regardless of the key.
*/
authDenyMode: 'slice' | 'all-secured';
}

const DEFAULTS: RequestValidationConfig = {
enumCaseInsensitive: false,
authAbsentMode: 'conditional',
authDenyMode: 'slice',
};

function isPlainObject(v: unknown): v is Record<string, unknown> {
Expand Down Expand Up @@ -103,5 +122,14 @@ export function loadRequestValidationConfig(
}
merged.authAbsentMode = v;
}
if ('authDenyMode' in parsed) {
const v = parsed.authDenyMode;
if (v !== 'slice' && v !== 'all-secured') {
throw new Error(
`Invalid ${configPath}: "authDenyMode" must be "slice" or "all-secured", got ${JSON.stringify(v)}.`,
);
}
merged.authDenyMode = v;
}
return merged;
}
21 changes: 19 additions & 2 deletions request-validation/templates/support/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export const denyProbeCredentials: ProbeCredentials = {
password: process.env.RBAC_DENY_PROBE_PASSWORD || 'rbac-deny-probe-pw',
};

/**
* Reduced-permission Bearer token for the deny probe in `authDenyMode:
* 'all-secured'` (e.g. Camunda Hub): a token that authenticates (valid signature
* + audience, so it passes the 401 gate) but lacks the operation's required
* permission, so the server denies it with 403. Minted from a Keycloak client
* scoped without the public-api authorities — see docker/start-hub.sh. When
* unset, deny-tests fall back to the Basic-auth zero-grant probe user (OCA).
*/
export const denyProbeBearerToken: string | undefined =
process.env.RBAC_DENY_PROBE_BEARER_TOKEN || undefined;

let partialCredsWarned = false;

function encode(value: string): string {
Expand All @@ -56,10 +67,16 @@ export function basicAuthHeaders(username: string, password: string): Record<str
}

/**
* Authorization header for the zero-grant RBAC deny-test probe user (#359).
* Never the admin — the whole point is that this principal lacks permissions.
* Authorization header for the RBAC deny-test probe — a principal that lacks the
* operation's permission so an authorizations-enabled server denies it with 403.
* Never the admin.
*
* Bearer when `RBAC_DENY_PROBE_BEARER_TOKEN` is set (the `all-secured` mode, e.g.
* Hub — a reduced-permission token minted from Keycloak); otherwise the Basic-auth
* zero-grant probe user the suite global-setup provisions (the OCA read-side slice).
*/
export function denyProbeHeaders(): Record<string, string> {
if (denyProbeBearerToken) return { Authorization: `Bearer ${denyProbeBearerToken}` };
return basicAuthHeaders(denyProbeCredentials.username, denyProbeCredentials.password);
}

Expand Down
20 changes: 19 additions & 1 deletion request-validation/templates/support/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
// the unsecured/secured suites are unaffected. All creates are idempotent — an
// already-existing resource (HTTP 409) is treated as success.

import { authHeaders, basicAuthHeaders, credentials, denyProbeCredentials } from './env';
import {
authHeaders,
basicAuthHeaders,
credentials,
denyProbeBearerToken,
denyProbeCredentials,
} from './env';

const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

Expand Down Expand Up @@ -80,6 +86,18 @@ const FIXTURES: ReadonlyArray<{ label: string; path: string; body: unknown }> =
async function globalSetup(): Promise<void> {
if (process.env.RV_PROFILE !== 'rbac') return;

// Bearer-probe mode (authDenyMode: 'all-secured', e.g. Hub): the deny probe is
// a reduced-permission token minted out-of-band (Keycloak), and the target
// authorizes before any resource lookup — so there are no fixtures to create
// and no probe USER to provision. Nothing to set up here.
if (denyProbeBearerToken) {
console.log(
'[rbac global-setup] Bearer-probe mode (RBAC_DENY_PROBE_BEARER_TOKEN set) — ' +
'no fixtures/probe-user provisioning needed.',
);
return;
}

const admin = authHeaders();
if (!admin.Authorization) {
throw new Error(
Expand Down
Loading