Skip to content
Merged
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
Binary file added acs-mqtt/hivemq-auth-krb-0.2.0-distribution.zip
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,27 @@ public static boolean looksLikeJwt (CharSequence creds)
return JWT_RX.matcher(creds).matches();
}

/* Verify signature, expiry and issuer, and return the kerberos UPN
* from preferred_username (stamped by the F+ Keycloak SPI). Makes
/* Verify signature, expiry and issuer, and return the Factory+
* principal. Prefer the fp_principal_uuid claim: it is stamped on
* every token our realm issues (federated users live, service
* accounts via the attribute service-setup stamps) and is the only
* identity a service account has - its preferred_username is a
* Keycloak-local name F+ Auth has never heard of. Fall back to the
* kerberos UPN for tokens from before the mapper existed. Makes
* network calls (discovery, JWKS); run on a worker thread. */
public String verify (String token) throws Exception
{
JWTClaimsSet claims = processor().process(token, null);
String uuid = claims.getStringClaim("fp_principal_uuid");
String upn = claims.getStringClaim("preferred_username");

if (uuid != null && !uuid.isBlank()) {
log.info("JWT verified for {} ({})", upn, uuid);
return uuid;
}
if (upn == null || upn.isBlank())
throw new SecurityException("JWT has no preferred_username claim");
throw new SecurityException(
"JWT has neither fp_principal_uuid nor preferred_username");
return upn;
}

Expand All @@ -98,9 +110,12 @@ private synchronized ConfigurableJWTProcessor<SecurityContext> processor ()
var proc = new DefaultJWTProcessor<SecurityContext>();
proc.setJWSKeySelector(
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keys));
/* Identity claims (fp_principal_uuid / preferred_username) are
* checked in verify(): which are present varies by grant type
* (service-account tokens may lack preferred_username). */
proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder().issuer(issuer).build(),
Set.of("exp", "preferred_username")));
Set.of("exp")));

processor = proc;
return processor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ public class FPKrbAuthProvider implements EnhancedAuthenticatorProvider
private static final UUID ADDR_UUID = UUID.fromString(
"8e32801b-f35a-4cbf-a5c3-2af64d3debd7");

private static final java.util.regex.Pattern UUID_SHAPE =
java.util.regex.Pattern.compile(
"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");

private FPServiceClient fplus;
private FPJwtVerifier jwt;

Expand Down Expand Up @@ -120,7 +124,13 @@ public TemplateUse (JSONObject tmpl, String targ)
}
}

return fplus.auth().getACL(principal, PERMGRP_UUID)
/* Principals from JWT auth are F+ principal UUIDs; kerberos
* auth yields UPNs (which always contain '@', so the shapes
* never collide). The Auth service routes each form through
* the matching lookup. */
final boolean by_uuid = UUID_SHAPE.matcher(principal).matches();

return fplus.auth().getACL(principal, PERMGRP_UUID, by_uuid)
.flatMapObservable(Observable::fromStream)
.flatMapSingle(ace -> {
String perm = (String)ace.get("permission");
Expand Down
60 changes: 60 additions & 0 deletions acs-service-setup/lib/openid.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import fs from "node:fs/promises";
import timers from "node:timers/promises";

import { UUIDs } from "@amrc-factoryplus/service-client";

const SECRET_DIR = "/etc/secret";
const READY_TIMEOUT_MS = 10 * 60 * 1000;
const READY_POLL_MS = 5 * 1000;
Expand All @@ -14,6 +16,7 @@ const TOKEN_REFRESH_BUFFER_MS = 10 * 1000;
class OpenIDSetup {
constructor (ss) {
const { fplus } = ss;
this.fplus = fplus;
this.log = fplus.debug.bound("openid");
this.config = ss.config;
this.acs = ss.acs_config;
Expand Down Expand Up @@ -396,6 +399,56 @@ class OpenIDSetup {
}
}

/** Give a service-account client an F+ identity.
*
* The SPI's claim mappers read the fp_principal_uuid user
* attribute, which for federated users is served live from F+
* Auth. A Keycloak service account is a local user with no F+
* record, so without help its tokens carry no F+ identity: the
* HTTP services reject them outright and the MQTT broker finds an
* empty ACL. Here we create a Principal object in ConfigDB for
* the service account and stamp its UUID as a real user attribute
* on the Keycloak user; the mappers pick it up exactly as they
* do the federated attribute, and admins grant permissions to the
* principal through the standard ACL editor.
*
* The Keycloak attribute is the source of truth for the UUID, so
* re-runs are idempotent and the UUID survives redeploys; the
* ConfigDB objects are re-ensured each run in case a dump reload
* lost them. */
async ensure_service_account_principal (name, spec, clientUuid) {
const base = `/admin/realms/${encodeURIComponent(this.realm)}`;
const res = await this.api("GET",
`${base}/clients/${clientUuid}/service-account-user`);
const user = res.body;
if (!user?.id) {
this.log("No service-account user for client %s; skipping", name);
return;
}

const cdb = this.fplus.ConfigDB;
const attrs = user.attributes ?? {};
let uuid = attrs.fp_principal_uuid?.[0];

if (!uuid) {
uuid = await cdb.create_object(UUIDs.Class.Principal);
this.log("Created principal %s for service account %s",
uuid, name);
}
await cdb.create_object(UUIDs.Class.Principal, uuid);
await cdb.put_config(UUIDs.App.Info, uuid,
{ name: `Service account: ${spec.name ?? name}` });

if (attrs.fp_principal_uuid?.[0] !== uuid) {
this.log("Stamping fp_principal_uuid on service account %s",
name);
await this.api("PUT", `${base}/users/${user.id}`, {
...user,
attributes: { ...attrs, fp_principal_uuid: [uuid] },
});
}
}

async run () {
await this.wait_for_ready();
await this.ensure_realm();
Expand Down Expand Up @@ -424,6 +477,13 @@ class OpenIDSetup {
// consumers (Grafana, acs-i3x, future shims) read F+ data
// directly from the token.
await this.ensure_factoryplus_claim_mappers(clientUuid);

// Service-account clients get an F+ principal so their
// client-credentials tokens carry an fp_principal_uuid
// and can be granted ACLs like any other principal.
if (!spec.publicClient && spec.serviceAccountsEnabled)
await this.ensure_service_account_principal(
name, spec, clientUuid);
}

this.log("OpenID setup complete");
Expand Down
11 changes: 10 additions & 1 deletion deploy/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,16 @@ serviceSetup:
# human login. Such clients are necessarily confidential (ignored
# if publicClient is set) and should also set
# `standardFlowEnabled: false`: they have no browser flow, so no
# redirect URIs are ever exercised. Example:
# redirect URIs are ever exercised.
#
# service-setup automatically creates an F+ Principal for each
# service-account client ("Service account: <name>" in the ACL
# editor) and stamps its UUID into the client's tokens as
# fp_principal_uuid, so the account can be granted F+ permissions
# (e.g. MQTT read for a visualiser wall) like any other principal.
# A fresh service account holds NO grants: an unattended display
# will authenticate but see nothing until an admin grants its
# principal the relevant permissions. Example:
# polyptic-kiosk:
# enabled: true
# name: Polyptic kiosk
Expand Down
25 changes: 22 additions & 3 deletions docs/auth/cli-and-jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,28 @@ service in is a single-line include in its deployment template.

The MQTT broker also accepts JWTs: present the JWT as the MQTT
password (the username is ignored; identity comes from the verified
token's `preferred_username`). The HiveMQ plugin validates against
the same realm via `OIDC_DISCOVERY_URL` and runs the normal MQTT ACL
lookup for that principal.
token's `fp_principal_uuid`, falling back to `preferred_username`).
The HiveMQ plugin validates against the same realm via
`OIDC_DISCOVERY_URL` and runs the normal MQTT ACL lookup for that
principal.

## Service accounts (client-credentials) have F+ principals

A Keycloak service account is a local Keycloak user, not one
federated from F+ Auth, so the SPI's live claim mappers have nothing
to serve for it. Instead, service-setup creates an F+ Principal for
every `serviceAccountsEnabled` client and stamps its UUID as a user
attribute on the service-account user; the claim mappers read the
attribute and stamp `fp_principal_uuid` into its tokens like any
other.

The principal appears in the ACL editor as "Service account:
<client name>" and starts with no grants. For an unattended
visualiser wall, grant it the MQTT read permissions (and Directory /
ConfigDB read for discovery and icons) before expecting the display
to show anything. `fp_permissions` is not populated for service
accounts; consumers that read it (Grafana's role mapping) see the
Viewer fallback, which is right for a wall.

## The visualiser

Expand Down
39 changes: 39 additions & 0 deletions docs/plans/2026-05-07-dev-cluster-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Dev cluster overrides for testing the dn/oAuth branch on a real cluster.
#
# Usage:
# helm upgrade <release> deploy/ \
# --namespace <staging-namespace> \
# --reuse-values \
# -f docs/plans/2026-05-07-dev-cluster-values.yaml
#
# --reuse-values pulls everything else from the existing helm release
# (cluster's domain, TLS, Postgres password etc.); this file only
# overrides the three images that actually changed on this branch.
#
# Replace <your-gh-username> and <your-tag> with your values from
# config.mk.

auth:
image:
# acs-auth image with the new GET /v2/principal/:uuid/groups endpoint
registry: ghcr.io/<your-gh-username>
tag: <your-tag>
pullPolicy: Always # pull every time during dev iteration

openid:
image:
# Custom Keycloak image: keycloak:26.1.1 + the F+ User Storage SPI
# jar baked in. Replaces the upstream Keycloak image.
repository: acs-keycloak
registry: ghcr.io/<your-gh-username>
tag: <your-tag>
pullPolicy: Always

serviceSetup:
image:
# service-setup with ensure_factoryplus_federation, the F+ claim
# mappers, and the Grafana group seeder. Helm runs this as a Job
# on every upgrade.
registry: ghcr.io/<your-gh-username>
tag: <your-tag>
pullPolicy: Always
Loading
Loading