Skip to main content

Register a client with a software statement

A software statement is a signed JWT that says what a client is and what it may do. A client sends it to /register in software_statement. Use one when an issuer you trust, such as your own platform team, should decide a client's grants instead of an admin entitling each client by hand.

The examples use POLICYARC_URL and POLICYARC_ADMIN_KEY, as on Register a client.

Trust the issuer​

The server accepts a statement only when its iss claim is on the trusted issuers list, in the row pbac.operator.controls.trust. A new environment has no such row, so every statement is refused with untrusted_issuer.

This block trusts your environment's own issuer, so it can issue statements in the next step. A PUT replaces the whole row. The row also holds spiffe_trust_bundle_url for SPIFFE workload identity, so keep any value it already has.

export ISSUER=$(curl -fsS "$POLICYARC_URL/.well-known/openid-configuration" | jq -er .issuer)
curl -fsS -X PUT "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.trust" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d "{\"dataKey\": \"pbac.operator.controls.trust\", \"payload\": {\"trusted_issuers\": [\"$ISSUER\"]}}"

Issue a statement​

Your environment can sign statements itself. The statement's sub is the softwareId you choose. Its iss is your environment's issuer. By default it expires after one year, set in seconds by PBAC_SOFTWARE_STATEMENT_LIFETIME_SECONDS.

resp=$(curl -fsS -X POST "$POLICYARC_URL/admin/api/software-statements" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{
"softwareId": "reports-app",
"grantTypes": ["client_credentials"],
"grantedResources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}],
"claims": {"department": "finance"}
}')
export STATEMENT=$(jq -er .softwareStatement <<<"$resp")

The response holds softwareStatement, the signed JWT, and softwareId. Anything in claims is added to the JWT as is, except iss, sub, aud, exp, iat and jti, which the server sets or drops.

A statement must declare two things, or registration is refused.

  • grant_types, a non-empty list
  • granted_resources for a client that consumes resources, or resources for a resource server. Each type must exist in the resource registry row, pbac.operator.controls.resource_registry.

Register with it​

sleep 5
resp=$(curl -sS -w '\n%{http_code}' -X POST "$POLICYARC_URL/register" \
-H 'Content-Type: application/json' \
-d "{\"client_name\": \"reports-app\", \"redirect_uris\": [\"https://reports-app.example/cb\"], \"software_statement\": \"$STATEMENT\"}")
echo "$resp" | cut -c1-300
[ "$(tail -n1 <<<"$resp")" = 201 ] || { echo 'Expected HTTP 201' >&2; false; }
export SS_CLIENT_ID=$(head -n1 <<<"$resp" | jq -er .client_id)
export SS_CLIENT_SECRET=$(head -n1 <<<"$resp" | jq -er .client_secret)

The response is HTTP 201. Its grant_types come from the statement. The client can get a token at once, with no entitlement entry, because the statement's claims are its entitlement.

curl -fsS -u "$SS_CLIENT_ID:$SS_CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=introspection | jq -e '.scope == "introspection"'

Every claim in the statement reaches policy as input.context.client.software_statement. Attribute-based access uses the department claim this way. One statement can register many clients. We registered twice with the same statement and got two client IDs.

The signature check is on by default​

PBAC_SOFTWARE_STATEMENT_VERIFY defaults to true. The server then verifies each statement's signature before policy sees it. A statement that fails is refused before any trust check.

b64() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '=\n'; }
unsigned="$(b64 '{"alg":"none","typ":"JWT"}').$(b64 "{\"iss\":\"$ISSUER\",\"sub\":\"forged-app\",\"grant_types\":[\"client_credentials\"],\"granted_resources\":[{\"type\":\"urn:as:introspect\",\"scopes\":[\"introspection\"]}]}")."
resp=$(curl -sS -w '\n%{http_code}' -X POST "$POLICYARC_URL/register" \
-H 'Content-Type: application/json' \
-d "{\"redirect_uris\": [\"https://forged.example/cb\"], \"software_statement\": \"$unsigned\"}")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"invalid_software_statement"' <<<"$resp" ||
{ echo 'Expected HTTP 400 invalid_software_statement' >&2; false; }

An unsigned statement, and a statement whose signature we changed, each returned HTTP 400 with this body. Each one writes a /register audit row with outcome error. The row's client is the issuer the statement claimed, and its request summary has "software_statement_verified": false.

{"error": "invalid_software_statement", "error_description": "The software statement could not be parsed or verified."}

How the server finds the key depends on the issuer.

  • A statement your environment issued is checked against its own software statement keys, with RS256.
  • Any other issuer must already be trusted. The server reads <iss>/.well-known/openid-configuration for jwks_uri, or falls back to <iss>/.well-known/jwks.json. By default both must be https addresses that are not private or loopback. PBAC_SAFE_HTTP_ALLOW_INSECURE_LOCALHOST=true lifts that rule for the hosts in PBAC_SAFE_HTTP_ALLOWED_INSECURE_HOSTS.

Setting PBAC_SOFTWARE_STATEMENT_VERIFY=false skips the signature check, so anyone who can reach /register can claim any trusted issuer. Use it only in a test environment.

Control which statements are accepted​

To do thisWrite thisWhat we saw
Refuse one software ID{"software_ids": ["blocked-app"]} in pbac.operator.controls.denylistunapproved_software_statement, Registration denied: denylist:software_id:blocked-app
Refuse one issuer{"issuers": ["<issuer>"]} in the same rowunapproved_software_statement, with reason denylist:issuer:<issuer>
Require a statement on every registration{"require_software_statement": true} in pbac.operator.controls.software_statement_validation, and enabled: false in pbac.operator.controls.public_client_policyinvalid_client_metadata, Registration denied: missing_software_statement
Refuse statements marked low trust"trust_level": "low" in the statementregistration_requires_approval, Registration requires additional steps: require_admin_approval, require_attestation. No client is created.

The require row alone does nothing while public_client_policy is enabled. With only that row set, a registration with no statement still returned HTTP 201. With both settings in place, MCP clients that register themselves are refused as well.

Other refusals we saw. A statement with no grant_types returned invalid_client_metadata with missing_explicit_declarations:grant_types. A statement that named a type missing from the registry returned unapproved_software_statement with invalid_resource_type. Every refusal is HTTP 400.

Grant more than the statement says​

The statement's claims are the client's entitlement only when no entry overrides them. An entry under software_id in pbac.operator.controls.client_entitlements, keyed by the statement's sub, applies to every client registered with that software ID. An entry under client_id applies to one client and wins over both. Clients and access gives the full order.