QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 38s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 38s
qbit release candidate / cut-rc (pull_request) Failing after 18s
Release candidate / release-rc (pull_request) Failing after 18s
qbit release candidate / tag-and-release (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 15m39s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 24s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 1h14m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 10s
Shared Java Maven CI with Docker / verify (push) Failing after 17m19s
Shared Java Maven CI with Docker / gate (push) Failing after 24s
qrun-release / release (push) Failing after 1h16m30s
qrun-release / postpublish (push) Skipped
Add user and group QQQ application-role access editing, bounded catalogs, webhook inbox and SQL mirrors, refreshed session permissions, and deferred invitation assignments. Include the isolated local review host, browser acceptance scripts, documentation and the token-widget column mapping fix. User approved this RC checkpoint. Full 1.0 gates remain documented, including the schema-drift matcher and PostgreSQL integration errors from the full gate. Companion UI: QRun-IO/qqq-frontend-next#205. Co-Authored-By: GPT-6 <noreply@openai.com>
124 lines
6.1 KiB
Java
124 lines
6.1 KiB
Java
/*
|
|
* Copyright 2026 Kingsrook, LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
package com.kingsrook.qbits.authentik.sync;
|
|
|
|
import java.util.UUID;
|
|
import com.kingsrook.qbits.authentik.admin.AuthentikInvitationEvidence;
|
|
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
|
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
|
import org.json.JSONObject;
|
|
|
|
/*******************************************************************************
|
|
** Re-fetch enrollment evidence and persist it under inbox ownership. This
|
|
** Paired intent is passed to the admin service for live authorization and
|
|
** audited, durable membership attempts.
|
|
*******************************************************************************/
|
|
public final class AuthentikInvitationEvents
|
|
{
|
|
private final AuthentikActionUtil client;
|
|
private final AuthentikInvitationAssignments assignments;
|
|
|
|
/***************************************************************************
|
|
** Share the configured directory and host database with the inbox processor.
|
|
***************************************************************************/
|
|
public AuthentikInvitationEvents(AuthentikActionUtil client, AuthentikInvitationAssignments assignments)
|
|
{
|
|
this.client = java.util.Objects.requireNonNull(client);
|
|
this.assignments = java.util.Objects.requireNonNull(assignments);
|
|
}
|
|
|
|
/***************************************************************************
|
|
** The envelope supplies only an event locator. All association, identity and
|
|
** chronology facts come from guarded authoritative API reads before locking.
|
|
** False means ownership was lost; rejected evidence must not be acknowledged.
|
|
***************************************************************************/
|
|
public boolean apply(AuthentikWebhookInbox inbox, AuthentikWebhookInbox.Claim claim) throws Exception
|
|
{
|
|
if(!(QContext.getQSession() instanceof QSystemUserSession))
|
|
{
|
|
throw (new QPermissionDeniedException("Invitation events require the internal session."));
|
|
}
|
|
var hint = AuthentikWebhookEnvelope.fromStorage(claim.payload());
|
|
if(!"invitation_used".equals(hint.action()) && !"user_write".equals(hint.action()))
|
|
{
|
|
return (true);
|
|
}
|
|
JSONObject event = client.syncEvent(hint.eventPk());
|
|
if(event == null || !hint.eventPk().equals(event.optString("pk")) || !hint.action().equals(event.optString("action")))
|
|
{
|
|
throw (new QException("Invitation event could not be verified."));
|
|
}
|
|
JSONObject context = event.optJSONObject("context");
|
|
boolean consumption = "invitation_used".equals(hint.action());
|
|
String locator = identifier(context == null ? null : context.opt(consumption ? "invitation_uuid" : AuthentikInvitationEvidence.REFERENCE), consumption);
|
|
if(locator == null)
|
|
{
|
|
// Ordinary account edits and invitations without saved assignments
|
|
// are unrelated to this workflow. Never infer identity from email.
|
|
return (true);
|
|
}
|
|
var intent = consumption ? assignments.findByInvitation(locator) : assignments.find(locator);
|
|
if(intent == null || !java.util.List.of("WAITING", "READY").contains(intent.status()))
|
|
{
|
|
return (true);
|
|
}
|
|
var evidence = new AuthentikInvitationEvidence(client);
|
|
if(consumption)
|
|
{
|
|
var fact = evidence.consumed(hint.eventPk(), intent.invitationId(), intent.flow(), intent.createdAt(), intent.expiresAt());
|
|
boolean saved = inbox.withClaim(claim, connection ->
|
|
{
|
|
requireRecorded(assignments.recordConsumption(connection, intent.reference(), fact));
|
|
});
|
|
return (saved && new com.kingsrook.qbits.authentik.admin.AuthentikAdmin(client)
|
|
.applyInvitationAssignments(intent.reference(), inbox, claim));
|
|
}
|
|
var fact = evidence.enrolled(hint.eventPk(), intent.reference(), intent.flow(), intent.createdAt(), intent.expiresAt());
|
|
JSONObject user = client.syncUser(fact.userPk().toString());
|
|
String userUuid = identifier(user == null ? null : user.opt("uuid"), false);
|
|
if(userUuid == null || !Boolean.TRUE.equals(user.opt("is_active")))
|
|
{
|
|
throw (new QException("Invitation account is unavailable or inactive."));
|
|
}
|
|
boolean saved = inbox.withClaim(claim, connection ->
|
|
{
|
|
requireRecorded(assignments.recordEnrollment(connection, intent.reference(), fact, userUuid));
|
|
});
|
|
return (saved && new com.kingsrook.qbits.authentik.admin.AuthentikAdmin(client)
|
|
.applyInvitationAssignments(intent.reference(), inbox, claim));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Refusal rolls back the inbox transaction and follows its bounded retries.
|
|
***************************************************************************/
|
|
private static void requireRecorded(boolean recorded) throws QException
|
|
{
|
|
if(!recorded)
|
|
{
|
|
throw (new QException("Invitation evidence was not accepted by the saved intent."));
|
|
}
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Only canonical UUIDs or native invitation UUID.hex are lookup candidates.
|
|
** A candidate locates saved intent; it does not establish trust in the event.
|
|
***************************************************************************/
|
|
private static String identifier(Object value, boolean allowHex)
|
|
{
|
|
if(!(value instanceof String text))
|
|
{
|
|
return (null);
|
|
}
|
|
if(allowHex && text.matches("[0-9a-fA-F]{32}"))
|
|
{
|
|
text = text.substring(0, 8) + "-" + text.substring(8, 12) + "-" + text.substring(12, 16) + "-" + text.substring(16, 20) + "-" + text.substring(20);
|
|
}
|
|
return (text.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") ? UUID.fromString(text).toString() : null);
|
|
}
|
|
}
|